Browse Source

Merge branch 'main' into feature/warm-up-portfolio-snapshot-calculation-during-biometric-authentication

pull/7427/head
Thomas Kaul 4 weeks ago
committed by GitHub
parent
commit
bf79c861ec
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 167
      CHANGELOG.md
  2. 12
      apps/api/src/app/account-balance/account-balance.service.ts
  3. 92
      apps/api/src/app/account/account.controller.ts
  4. 112
      apps/api/src/app/account/account.service.ts
  5. 4
      apps/api/src/app/account/interfaces/cash-details.interface.ts
  6. 21
      apps/api/src/app/activities/activities-filter.dto.ts
  7. 97
      apps/api/src/app/activities/activities.controller.ts
  8. 43
      apps/api/src/app/activities/activities.service.ts
  9. 27
      apps/api/src/app/activities/get-activities.dto.ts
  10. 17
      apps/api/src/app/admin/admin.service.ts
  11. 10
      apps/api/src/app/app.module.ts
  12. 3
      apps/api/src/app/asset/asset.controller.ts
  13. 7
      apps/api/src/app/auth/api-key.strategy.ts
  14. 7
      apps/api/src/app/auth/jwt.strategy.ts
  15. 18
      apps/api/src/app/endpoints/ai/ai.controller.ts
  16. 98
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  17. 4
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts
  18. 53
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  19. 37
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  20. 12
      apps/api/src/app/endpoints/benchmarks/get-benchmark-market-data.dto.ts
  21. 32
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  22. 21
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  23. 5
      apps/api/src/app/endpoints/tags/tags.controller.ts
  24. 43
      apps/api/src/app/export/export.controller.ts
  25. 21
      apps/api/src/app/export/export.service.ts
  26. 14
      apps/api/src/app/export/get-export.dto.ts
  27. 4
      apps/api/src/app/health/health.service.ts
  28. 7
      apps/api/src/app/import/import-data.dto.ts
  29. 1
      apps/api/src/app/import/import.controller.ts
  30. 244
      apps/api/src/app/import/import.service.ts
  31. 18
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  32. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  33. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  34. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  35. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  36. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts
  37. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  38. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  39. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  40. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  41. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  42. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  43. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  44. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts
  45. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  46. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  47. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  48. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts
  49. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  50. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  51. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  52. 7
      apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts
  53. 12
      apps/api/src/app/portfolio/get-details.dto.ts
  54. 10
      apps/api/src/app/portfolio/get-dividends.dto.ts
  55. 14
      apps/api/src/app/portfolio/get-holdings.dto.ts
  56. 10
      apps/api/src/app/portfolio/get-investments.dto.ts
  57. 12
      apps/api/src/app/portfolio/get-performance.dto.ts
  58. 187
      apps/api/src/app/portfolio/portfolio.controller.ts
  59. 97
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  60. 249
      apps/api/src/app/portfolio/portfolio.service.ts
  61. 3
      apps/api/src/app/redis-cache/redis-cache.service.mock.ts
  62. 4
      apps/api/src/app/subscription/subscription.controller.ts
  63. 10
      apps/api/src/app/user/user.controller.ts
  64. 4
      apps/api/src/app/user/user.module.ts
  65. 88
      apps/api/src/app/user/user.service.ts
  66. 16
      apps/api/src/dtos/date-range-filter.dto.ts
  67. 23
      apps/api/src/dtos/filter.dto.ts
  68. 26
      apps/api/src/filters/portfolio-snapshot-computation-exception.filter.ts
  69. 18
      apps/api/src/helper/account.helper.ts
  70. 2
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  71. 2
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  72. 12
      apps/api/src/services/asset-profile-split/asset-profile-split.module.ts
  73. 87
      apps/api/src/services/asset-profile-split/asset-profile-split.service.ts
  74. 6
      apps/api/src/services/benchmark/benchmark.service.ts
  75. 14
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  76. 12
      apps/api/src/services/data-provider/data-provider.service.ts
  77. 2
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  78. 6
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  79. 70
      apps/api/src/services/property/property.service.ts
  80. 2
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  81. 29
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts
  82. 21
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  83. 15
      apps/client/src/app/app.component.ts
  84. 45
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  85. 158
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  86. 2
      apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
  87. 62
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  88. 17
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  89. 113
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  90. 129
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  91. 41
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  92. 18
      apps/client/src/app/components/admin-overview/admin-overview.html
  93. 13
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  94. 25
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  95. 3
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  96. 12
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  97. 9
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  98. 2
      apps/client/src/app/components/admin-users/admin-users.html
  99. 109
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  100. 24
      apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html

167
CHANGELOG.md

@ -9,16 +9,183 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a live preview of the date and number format to the user settings
- Added the country flag to the currency selector
- Added a _Storybook_ story for the currency selector component
- Extended the entity logo component by a `hasPlaceholder` attribute to reserve the space of a missing logo
- Warmed up the portfolio snapshot calculation in the background during the biometric authentication - Warmed up the portfolio snapshot calculation in the background during the biometric authentication
### Changed ### Changed
- Improved the usability of the create watchlist item dialog by setting the initial focus to the search field
- Migrated the abstract _Material_ form field from a component to a directive
- Removed the redundant `balance` attribute of the account in favor of the account balances
### Fixed
- Fixed the values of the charts and tables in impersonation mode with an unrestricted access to show absolute values instead of percentages
- Fixed the savings rate of the investment timeline chart and the streaks on the analysis page in impersonation mode to be based on the impersonated user
- Fixed the savings rate of the _FIRE_ calculator in impersonation mode to not be based on the impersonating user
## 3.43.0 - 2026-08-06
### Added
- Added the platform logo to the platform selector in the create or update account dialog
- Added the platform logo to the account selector in the create or update activity dialog
- Extended the value component by an `isLoading` attribute to distinguish the loading state from redacted values
### Changed
- Guarded the system tags against deletion and renaming in the tag management of the admin control panel
- Improved the language localization for Spanish (`es`)
### Fixed
- Handled an exception in the country weightings parsing of the _Financial Modeling Prep_ service
## 3.42.0 - 2026-08-04
### Changed
- Improved the usability of the portfolio summary by collapsing the _Holdings_ and _Cash_ breakdowns by default
- Extended the support of the _Exclude from Analysis_ tag from accounts to activities
- Optimized the performance of the search in the assistant by reusing the cached portfolio snapshot
- Improved the validation of the import functionality when referencing an asset profile with the data source `MANUAL`
- Improved the validation of the endpoint to add a custom asset profile in the admin control panel
### Fixed
- Fixed the fuzzy search for the holdings in the assistant
## 3.41.0 - 2026-08-03
### Added
- Added support for the account platforms in the activities import
- Added the database model and endpoints to manage the stock splits of an asset profile (experimental)
### Changed
- Improved the usability of the admin control panel by eliminating the page reload on changing a setting
- Improved the usability of the admin control panel by eliminating the page reload on deleting an asset profile
- Improved the usability of the admin control panel by eliminating the page reload on flushing the cache
- Improved the usability of the admin control panel by eliminating the page reload on gathering historical market data
- Improved the language localization for German (`de`)
### Fixed
- Fixed the loading state in the user detail dialog of the admin control panel’s users section
- Fixed a race condition where the portfolio snapshot computation was completed before its result had been cached, causing a redundant recomputation
- Fixed an endless loop in the portfolio snapshot computation if the computed result could not be read from the cache
## 3.40.0 - 2026-08-02
### Changed
- Improved the style of the read-only tags in the tags selector component
- Improved the language localization for Chinese (`zh`)
- Upgraded `nestjs` from version `11.1.27` to `11.1.28`
### Fixed
- Fixed the handling of the _Exclude from Analysis_ tag in the activities table
- Fixed the persistence of an empty comment in the create or update account dialog
- Resolved a validation error caused by empty strings in the asset profile details dialog of the admin control panel
## 3.39.0 - 2026-08-01
### Changed
- Harmonized the data format of the export functionality
- Removed the deprecated `firstOrderDate` attribute from the `GET api/v2/portfolio/performance` endpoint response
- Removed the deprecated `isExcluded` attribute of the account in favor of the _Exclude from Analysis_ tag including a data migration
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `7.8.0` to `7.9.1`
### Fixed
- Fixed the scroll behavior of the page content behind an open dialog
- Fixed the export functionality to only include the accounts of the exported activities if a filter is applied
## 3.38.0 - 2026-07-31
### Added
- Added support for the date range filter in the export functionality
- Added support for the date range filter on the portfolio activities page
### Changed
- Improved the style of the tabs in the account detail dialog on mobile
- Improved the style of the tabs in the holding detail dialog on mobile
- Improved the style of the tabs in the asset profile dialog of the admin control panel on mobile
- Improved the style of the empty state in the _Fear & Greed Index_ component
- Added the activity count to the delete menu item of the activities table
- Added the activity count to the deletion confirmation dialog of the activities table
- Improved the style of the type filter in the activities table component (experimental)
- Improved the search functionality by trimming the query
- Improved the log output in the search functionality of the _Yahoo Finance_ service for unsupported queries
- Improved the performance of the property service by caching the properties in memory
- Improved the validation of the query parameters in the activities endpoints
- Improved the language localization for German (`de`)
### Fixed
- Fixed the calendar year date range in time zones with a negative _UTC_ offset
- Fixed the deletion of activities to respect the activity type filter on the activities page (experimental)
- Fixed the deletion of activities to respect the date range filter on the activities page
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Equity)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Fixed Income)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment: Base Currency)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Economic Market Cluster Risks_ (Developed Markets)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Economic Market Cluster Risks_ (Emerging Markets)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Asia-Pacific)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Emerging Markets)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Europe)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Japan)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (North America)
## 3.37.0 - 2026-07-30
### Added
- Added an empty state to the _Fear & Greed Index_ component
- Added a _Storybook_ story for the _Fear & Greed Index_ component
### Changed
- Moved the tags to the overview tab of the account detail dialog (experimental)
- Moved the tags to the overview tab of the holding detail dialog
- Consolidated the markets pages into a single route where the _Fear & Greed Index_ is controlled by permission
- Refactored the line chart components to share the common chart configuration
- Improved the language localization for Spanish (`es`)
- Improved the language localization for Ukrainian (`uk`)
### Fixed
- Ignored future-dated account balances in the portfolio calculation
## 3.36.0 - 2026-07-29
### Added
- Added an overview tab to the account detail dialog
- Added the tags (read-only) to the account detail dialog (experimental)
### Changed
- Improved the portfolio summary tab on the home page
- Improved the language localization for German (`de`)
- Upgraded `@openrouter/ai-sdk-provider` from version `2.9.1` to `3.0.0` - Upgraded `@openrouter/ai-sdk-provider` from version `2.9.1` to `3.0.0`
- Upgraded `ai` from version `6.0.174` to `7.0.37` - Upgraded `ai` from version `6.0.174` to `7.0.37`
### Fixed ### Fixed
- Fixed the time in market of the portfolio summary to be empty if there is no activity - Fixed the time in market of the portfolio summary to be empty if there is no activity
- Fixed an issue with the delete button in the activities filter component
- Fixed the tags in the read-only mode of the tags selector component
## 3.35.0 - 2026-07-27 ## 3.35.0 - 2026-07-27

12
apps/api/src/app/account-balance/account-balance.service.ts

@ -1,5 +1,8 @@
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; import {
isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -15,7 +18,7 @@ import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client'; import { AccountBalance, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns'; import { endOfToday, format, parseISO } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@Injectable() @Injectable()
@ -114,8 +117,13 @@ export class AccountBalanceService {
const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } = const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } =
{}; {};
const lastBalancesByAccount: { [accountId: string]: Big } = {}; const lastBalancesByAccount: { [accountId: string]: Big } = {};
const endOfTodayDate = endOfToday();
for (const { accountId, date, valueInBaseCurrency } of balances) { for (const { accountId, date, valueInBaseCurrency } of balances) {
if (isAccountBalanceInFuture({ date, endOfTodayDate })) {
continue;
}
const formattedDate = format(date, DATE_FORMAT); const formattedDate = format(date, DATE_FORMAT);
lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency); lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency);

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

@ -156,32 +156,34 @@ export class AccountController {
public async createAccount( public async createAccount(
@Body() data: CreateAccountDto @Body() data: CreateAccountDto
): Promise<AccountModel> { ): Promise<AccountModel> {
const { tags: tagIds, ...accountData } = data; const { balance, tags: tagIds, ...accountData } = data;
if (accountData.platformId) { if (accountData.platformId) {
const platformId = accountData.platformId; const platformId = accountData.platformId;
delete accountData.platformId; delete accountData.platformId;
return this.accountService.createAccount( return this.accountService.createAccount({
{ balance,
tagIds,
data: {
...accountData, ...accountData,
platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id, userId: this.request.user.id
tagIds });
);
} else { } else {
delete accountData.platformId; delete accountData.platformId;
return this.accountService.createAccount( return this.accountService.createAccount({
{ balance,
tagIds,
data: {
...accountData, ...accountData,
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id, userId: this.request.user.id
tagIds });
);
} }
} }
@ -257,52 +259,50 @@ export class AccountController {
); );
} }
const { tags: tagIds, ...accountData } = data; const { balance, tags: tagIds, ...accountData } = data;
if (accountData.platformId) { if (accountData.platformId) {
const platformId = accountData.platformId; const platformId = accountData.platformId;
delete accountData.platformId; delete accountData.platformId;
return this.accountService.updateAccount( return this.accountService.updateAccount({
{ balance,
data: { tagIds,
...accountData, data: {
platform: { connect: { id: platformId } }, ...accountData,
user: { connect: { id: this.request.user.id } } platform: { connect: { id: platformId } },
}, user: { connect: { id: this.request.user.id } }
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
}, },
this.request.user.id, userId: this.request.user.id,
tagIds where: {
); id_userId: {
id,
userId: this.request.user.id
}
}
});
} else { } else {
// platformId is null, remove it // platformId is null, remove it
delete accountData.platformId; delete accountData.platformId;
return this.accountService.updateAccount( return this.accountService.updateAccount({
{ balance,
data: { tagIds,
...accountData, data: {
platform: originalAccount.platformId ...accountData,
? { disconnect: true } platform: originalAccount.platformId
: undefined, ? { disconnect: true }
user: { connect: { id: this.request.user.id } } : undefined,
}, user: { connect: { id: this.request.user.id } }
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
}, },
this.request.user.id, userId: this.request.user.id,
tagIds where: {
); id_userId: {
id,
userId: this.request.user.id
}
}
});
} }
} }
} }

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

@ -1,11 +1,16 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; import {
getWhereAccountBalanceNotInFuture,
isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { AccountWithBalance } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
@ -19,8 +24,8 @@ import {
Tag Tag
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format } from 'date-fns'; import { endOfToday, format } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy, isNil } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@ -36,12 +41,14 @@ export class AccountService {
public async account({ public async account({
id_userId id_userId
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> { }: Prisma.AccountWhereUniqueInput): Promise<AccountWithBalance | null> {
const account = await this.prismaService.account.findUnique({ const account = await this.prismaService.account.findUnique({
include: { include: {
balances: { balances: {
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
take: 1 take: 1,
// Ignore account balances in the future
where: getWhereAccountBalanceNotInFuture()
} }
}, },
where: { id_userId } where: { id_userId }
@ -81,7 +88,7 @@ export class AccountService {
where?: Prisma.AccountWhereInput; where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByWithRelationInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(Account & { (AccountWithBalance & {
activities?: (Order & { SymbolProfile?: SymbolProfile })[]; activities?: (Order & { SymbolProfile?: SymbolProfile })[];
balances?: AccountBalance[]; balances?: AccountBalance[];
platform?: Platform; platform?: Platform;
@ -95,7 +102,16 @@ export class AccountService {
include.balances = { include.balances = {
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
...(isBalancesIncluded ? {} : { take: 1 }) // If the balances are included, they are returned as-is (including the
// ones in the future) because the client renders the full history. The
// balance is derived below and skips the account balances in the future.
...(isBalancesIncluded
? {}
: {
take: 1,
// Ignore account balances in the future
where: getWhereAccountBalanceNotInFuture()
})
}; };
if (isTagsIncluded) { if (isTagsIncluded) {
@ -115,10 +131,17 @@ export class AccountService {
where where
}); });
const endOfTodayDate = endOfToday();
return accounts.map((account) => { return accounts.map((account) => {
const result = { const result = {
...account, ...account,
balance: account.balances[0]?.value ?? 0, balance:
// The balances are ordered by date descending, hence the first account
// balance which is not in the future reflects the current balance
account.balances.find(({ date }) => {
return !isAccountBalanceInFuture({ date, endOfTodayDate });
})?.value ?? 0,
tags: isTagsIncluded tags: isTagsIncluded
? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => { ? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => {
return tag; return tag;
@ -138,12 +161,18 @@ export class AccountService {
}); });
} }
public async createAccount( public async createAccount({
data: Prisma.AccountCreateInput, balance,
aUserId: string, data,
tagIds?: string[] tagIds,
): Promise<Account> { userId
await this.tagService.validateTagIds({ tagIds, userId: aUserId }); }: {
balance?: number;
data: Prisma.AccountCreateInput;
tagIds?: string[];
userId: string;
}): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId });
const account = await this.prismaService.account.create({ const account = await this.prismaService.account.create({
data: { data: {
@ -160,12 +189,14 @@ export class AccountService {
} }
}); });
await this.accountBalanceService.createOrUpdateAccountBalance({ if (!isNil(balance)) {
accountId: account.id, await this.accountBalanceService.createOrUpdateAccountBalance({
balance: data.balance, balance,
date: format(new Date(), DATE_FORMAT), userId,
userId: aUserId accountId: account.id,
}); date: format(new Date(), DATE_FORMAT)
});
}
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
@ -194,7 +225,7 @@ export class AccountService {
return account; return account;
} }
public async getAccounts(aUserId: string): Promise<Account[]> { public async getAccounts(aUserId: string): Promise<AccountWithBalance[]> {
const accounts = await this.accounts({ const accounts = await this.accounts({
include: { include: {
activities: true, activities: true,
@ -273,17 +304,20 @@ export class AccountService {
}; };
} }
public async updateAccount( public async updateAccount({
params: { balance,
data: Prisma.AccountUpdateInput; data,
where: Prisma.AccountWhereUniqueInput; tagIds,
}, userId,
aUserId: string, where
tagIds?: string[] }: {
): Promise<Account> { balance?: number;
const { data, where } = params; data: Prisma.AccountUpdateInput;
tagIds?: string[];
await this.tagService.validateTagIds({ tagIds, userId: aUserId }); userId: string;
where: Prisma.AccountWhereUniqueInput;
}): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId });
const account = await this.prismaService.account.update({ const account = await this.prismaService.account.update({
data: { data: {
@ -302,12 +336,14 @@ export class AccountService {
where where
}); });
await this.accountBalanceService.createOrUpdateAccountBalance({ if (!isNil(balance)) {
accountId: account.id, await this.accountBalanceService.createOrUpdateAccountBalance({
balance: data.balance as number, balance,
date: format(new Date(), DATE_FORMAT), userId,
userId: aUserId accountId: account.id,
}); date: format(new Date(), DATE_FORMAT)
});
}
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),

4
apps/api/src/app/account/interfaces/cash-details.interface.ts

@ -1,6 +1,6 @@
import { Account } from '@prisma/client'; import { AccountWithBalance } from '@ghostfolio/common/types';
export interface CashDetails { export interface CashDetails {
accounts: Account[]; accounts: AccountWithBalance[];
balanceInBaseCurrency: number; balanceInBaseCurrency: number;
} }

21
apps/api/src/app/activities/activities-filter.dto.ts

@ -0,0 +1,21 @@
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { DateRange } from '@ghostfolio/common/types';
import { Type as ActivityType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsEnum, IsOptional, Matches } from 'class-validator';
import { isString } from 'lodash';
export class ActivitiesFilterDto extends FilterDto {
@IsEnum(ActivityType, { each: true })
@IsOptional()
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
activityTypes?: ActivityType[];
@IsOptional()
@Matches(DATE_RANGE_PATTERN)
range?: DateRange;
}

97
apps/api/src/app/activities/activities.controller.ts

@ -18,7 +18,7 @@ import {
ActivityResponse ActivityResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -29,7 +29,6 @@ import {
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseIntPipe,
Post, Post,
Put, Put,
Query, Query,
@ -38,11 +37,13 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Order, Prisma, Type as ActivityType } from '@prisma/client'; import { Order } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ActivitiesFilterDto } from './activities-filter.dto';
import { ActivitiesService } from './activities.service'; import { ActivitiesService } from './activities.service';
import { GetActivitiesDto } from './get-activities.dto';
@Controller('activities') @Controller('activities')
export class ActivitiesController { export class ActivitiesController {
@ -60,22 +61,47 @@ export class ActivitiesController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteActivities( public async deleteActivities(
@Query('accounts') filterByAccounts?: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query()
@Query('dataSource') filterByDataSource?: string, {
@Query('symbol') filterBySymbol?: string, accounts,
@Query('tags') filterByTags?: string activityTypes,
assetClasses,
dataSource,
range,
symbol,
tags
}: ActivitiesFilterDto
): Promise<number> { ): Promise<number> {
if (impersonationId) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let endDate: Date;
let startDate: Date;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
return this.activitiesService.deleteActivities({ return this.activitiesService.deleteActivities({
endDate,
filters, filters,
startDate,
types: activityTypes,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@ -108,38 +134,41 @@ export class ActivitiesController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllActivities( public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('activityTypes') filterByTypes?: string, {
@Query('assetClasses') filterByAssetClasses?: string, accounts,
@Query('dataSource') filterByDataSource?: string, activityTypes,
@Query('range') dateRange?: DateRange, assetClasses,
@Query('skip', new ParseIntPipe({ optional: true })) skip?: number, dataSource,
@Query('sortColumn') sortColumn?: string, range,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, skip,
@Query('symbol') filterBySymbol?: string, sortColumn,
@Query('tags') filterByTags?: string, sortDirection,
@Query('take', new ParseIntPipe({ optional: true })) take?: number symbol,
tags,
take
}: GetActivitiesDto
): Promise<ActivitiesResponse> { ): Promise<ActivitiesResponse> {
let endDate: Date; let endDate: Date;
let startDate: Date; let startDate: Date;
if (dateRange) { if (range) {
({ endDate, startDate } = getIntervalFromDateRange({ dateRange })); ({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
} }
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.activitiesService.getActivities({ const { activities, count } = await this.activitiesService.getActivities({
@ -150,9 +179,9 @@ export class ActivitiesController {
sortDirection, sortDirection,
startDate, startDate,
take, take,
types,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
types: activityTypes,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });

43
apps/api/src/app/activities/activities.service.ts

@ -3,7 +3,10 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; import {
isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -17,12 +20,13 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
ghostfolioPrefix, NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
getAssetProfileIdentifier getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse, ActivitiesResponse,
@ -45,7 +49,6 @@ import {
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -189,7 +192,7 @@ export class ActivitiesService {
const userId = data.userId; const userId = data.userId;
if ( if (
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) || NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' && (data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' &&
data.type === 'BUY') data.type === 'BUY')
) { ) {
@ -201,10 +204,9 @@ export class ActivitiesService {
let symbol: string; let symbol: string;
if ( if (
data.SymbolProfile.connectOrCreate.create.symbol.startsWith( isValidCustomAssetProfileSymbol(
`${ghostfolioPrefix}_` data.SymbolProfile.connectOrCreate.create.symbol
) || )
isUUID(data.SymbolProfile.connectOrCreate.create.symbol)
) { ) {
// Connect custom asset profile (clone) // Connect custom asset profile (clone)
symbol = data.SymbolProfile.connectOrCreate.create.symbol; symbol = data.SymbolProfile.connectOrCreate.create.symbol;
@ -259,7 +261,7 @@ export class ActivitiesService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) const isDraft = NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type)
? false ? false
: isAfter(data.date as Date, endOfToday()); : isAfter(data.date as Date, endOfToday());
@ -358,14 +360,23 @@ export class ActivitiesService {
} }
public async deleteActivities({ public async deleteActivities({
endDate,
filters, filters,
startDate,
types,
userId userId
}: { }: {
endDate?: Date;
filters?: Filter[]; filters?: Filter[];
startDate?: Date;
types?: ActivityType[];
userId: string; userId: string;
}): Promise<number> { }): Promise<number> {
const { activities } = await this.getActivities({ const { activities } = await this.getActivities({
endDate,
filters, filters,
startDate,
types,
userId, userId,
includeDrafts: true, includeDrafts: true,
userCurrency: undefined, userCurrency: undefined,
@ -456,6 +467,7 @@ export class ActivitiesService {
} }
const activities: Activity[] = []; const activities: Activity[] = [];
const endOfTodayDate = endOfToday();
for (const account of cashDetails.accounts) { for (const account of cashDetails.accounts) {
const { balances } = await this.accountBalanceService.getAccountBalances({ const { balances } = await this.accountBalanceService.getAccountBalances({
@ -468,6 +480,15 @@ export class ActivitiesService {
let currentBalanceInBaseCurrency = 0; let currentBalanceInBaseCurrency = 0;
for (const balanceItem of balances) { for (const balanceItem of balances) {
if (
isAccountBalanceInFuture({
endOfTodayDate,
date: balanceItem.date
})
) {
continue;
}
const syntheticActivityTemplate: Activity = { const syntheticActivityTemplate: Activity = {
userId, userId,
accountId: account.id, accountId: account.id,
@ -961,7 +982,7 @@ export class ActivitiesService {
let isDraft = false; let isDraft = false;
if ( if (
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) || NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' && (data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' &&
data.type === 'BUY') data.type === 'BUY')
) { ) {

27
apps/api/src/app/activities/get-activities.dto.ts

@ -0,0 +1,27 @@
import { Prisma } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Min } from 'class-validator';
import { ActivitiesFilterDto } from './activities-filter.dto';
export class GetActivitiesDto extends ActivitiesFilterDto {
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
skip?: number;
@IsIn(Object.values(Prisma.OrderScalarFieldEnum))
@IsOptional()
sortColumn?: keyof typeof Prisma.OrderScalarFieldEnum;
@IsIn(['asc', 'desc'] as Prisma.SortOrder[])
@IsOptional()
sortDirection?: Prisma.SortOrder;
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
take?: number;
}

17
apps/api/src/app/admin/admin.service.ts

@ -7,6 +7,7 @@ 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
ghostfolioPrefix,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
@ -14,7 +15,8 @@ import {
import { import {
applyAssetProfileOverrides, applyAssetProfileOverrides,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCurrencyFromSymbol getCurrencyFromSymbol,
hasGhostfolioPrefix
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
@ -63,6 +65,12 @@ export class AdminService {
> { > {
try { try {
if (dataSource === 'MANUAL') { if (dataSource === 'MANUAL') {
if (!hasGhostfolioPrefix(symbol)) {
throw new BadRequestException(
`symbol ("${symbol}") must start with the prefix "${ghostfolioPrefix}_" for the data source ("${dataSource}")`
);
}
return this.symbolProfileService.add({ return this.symbolProfileService.add({
currency, currency,
dataSource, dataSource,
@ -107,8 +115,11 @@ export class AdminService {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
const currency = getCurrencyFromSymbol(symbol); const currency = getCurrencyFromSymbol(symbol);
const customCurrencies =
await this.propertyService.getByKey<string[]>(PROPERTY_CURRENCIES); const customCurrencies = await this.propertyService.getByKey<string[]>(
PROPERTY_CURRENCIES,
{ skipCache: true }
);
if (customCurrencies.includes(currency)) { if (customCurrencies.includes(currency)) {
const updatedCustomCurrencies = customCurrencies.filter( const updatedCustomCurrencies = customCurrencies.filter(

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

@ -1,4 +1,5 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { EventsModule } from '@ghostfolio/api/events/events.module';
import { PortfolioSnapshotComputationExceptionFilter } from '@ghostfolio/api/filters/portfolio-snapshot-computation-exception.filter';
import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper'; import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper';
import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
@ -24,6 +25,7 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { APP_FILTER } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
@ -184,7 +186,13 @@ import { UserModule } from './user/user.module';
UserModule, UserModule,
WatchlistModule WatchlistModule
], ],
providers: [I18nService] providers: [
I18nService,
{
provide: APP_FILTER,
useClass: PortfolioSnapshotComputationExceptionFilter
}
]
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
public configure(consumer: MiddlewareConsumer) { public configure(consumer: MiddlewareConsumer) {

3
apps/api/src/app/asset/asset.controller.ts

@ -20,7 +20,7 @@ export class AssetController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<AssetResponse> { ): Promise<AssetResponse> {
const { assetProfile, marketData } = const { assetProfile, marketData, splits } =
await this.assetProfilesService.getAssetProfile({ await this.assetProfilesService.getAssetProfile({
dataSource, dataSource,
symbol symbol
@ -28,6 +28,7 @@ export class AssetController {
return { return {
marketData, marketData,
splits,
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol']) assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
}; };
} }

7
apps/api/src/app/auth/api-key.strategy.ts

@ -35,6 +35,13 @@ export class ApiKeyStrategy extends PassportStrategy(
); );
} }
if (await this.userService.isDailyRequestLimitExceeded({ user })) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
await this.prismaService.analytics.upsert({ await this.prismaService.analytics.upsert({
create: { user: { connect: { id: user.id } } }, create: { user: { connect: { id: user.id } } },
update: { update: {

7
apps/api/src/app/auth/jwt.strategy.ts

@ -42,6 +42,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
); );
} }
if (await this.userService.isDailyRequestLimitExceeded({ user })) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
const country = const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id; countriesAndTimezones.getCountryForTimezone(timezone)?.id;

18
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -1,4 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { AiPromptResponse } from '@ghostfolio/common/interfaces'; import { AiPromptResponse } from '@ghostfolio/common/interfaces';
@ -31,18 +32,15 @@ export class AiController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPrompt( public async getPrompt(
@Param('mode') mode: AiPromptMode, @Param('mode') mode: AiPromptMode,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('assetClasses') filterByAssetClasses?: string, { accounts, assetClasses, dataSource, symbol, tags }: FilterDto
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<AiPromptResponse> { ): Promise<AiPromptResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
const prompt = await this.aiService.getPrompt({ const prompt = await this.aiService.getPrompt({

98
apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts

@ -4,7 +4,10 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; import {
CreateAssetProfileSplitDto,
UpdateAssetProfileDataDto
} from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { AssetProfileResponse } from '@ghostfolio/common/interfaces'; import { AssetProfileResponse } from '@ghostfolio/common/interfaces';
import { import {
@ -18,19 +21,22 @@ import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseIntPipe, ParseIntPipe,
Patch, Patch,
Post,
Query, Query,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, Prisma } from '@prisma/client'; import { AssetProfileSplit, DataSource, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AssetProfilesService } from './asset-profiles.service'; import { AssetProfilesService } from './asset-profiles.service';
@ -119,6 +125,51 @@ export class AssetProfilesController {
}); });
} }
@Post(':dataSource/:symbol/splits')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createSplit(
@Body() data: CreateAssetProfileSplitDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AssetProfileSplit> {
const { id: symbolProfileId } = await this.validateAccessToSplits({
dataSource,
symbol,
permission: permissions.createAssetProfileSplit,
permissionOfOwnAssetProfile:
permissions.createAssetProfileSplitOfOwnAssetProfile
});
return this.assetProfilesService.createSplit({
dataSource,
symbol,
symbolProfileId,
date: parseISO(data.date),
denominator: data.denominator,
numerator: data.numerator
});
}
@Delete(':dataSource/:symbol/splits/:id')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteSplit(
@Param('dataSource') dataSource: DataSource,
@Param('id') id: string,
@Param('symbol') symbol: string
): Promise<void> {
const { id: symbolProfileId } = await this.validateAccessToSplits({
dataSource,
symbol,
permission: permissions.deleteAssetProfileSplit,
permissionOfOwnAssetProfile:
permissions.deleteAssetProfileSplitOfOwnAssetProfile
});
return this.assetProfilesService.deleteSplit({ id, symbolProfileId });
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Patch(':dataSource/:symbol') @Patch(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -139,4 +190,47 @@ export class AssetProfilesController {
assetProfileData assetProfileData
); );
} }
private async validateAccessToSplits({
dataSource,
permission,
permissionOfOwnAssetProfile,
symbol
}: {
dataSource: DataSource;
permission: string;
permissionOfOwnAssetProfile: string;
symbol: string;
}) {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (!assetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const canAccessAllAssetProfiles = hasPermission(
this.request.user.permissions,
permission
);
const canAccessOwnAssetProfile =
assetProfile.userId === this.request.user.id &&
hasPermission(this.request.user.permissions, permissionOfOwnAssetProfile);
if (!canAccessAllAssetProfiles && !canAccessOwnAssetProfile) {
throw new HttpException(
assetProfile.userId
? getReasonPhrase(StatusCodes.NOT_FOUND)
: getReasonPhrase(StatusCodes.FORBIDDEN),
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
);
}
return assetProfile;
}
} }

4
apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts

@ -2,11 +2,13 @@ import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.modu
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -20,7 +22,9 @@ import { AssetProfilesService } from './asset-profiles.service';
imports: [ imports: [
ActivitiesModule, ActivitiesModule,
ApiModule, ApiModule,
AssetProfileSplitModule,
BenchmarkModule, BenchmarkModule,
DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,

53
apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts

@ -1,9 +1,11 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
import { import {
@ -30,7 +32,9 @@ import { groupBy } from 'lodash';
export class AssetProfilesService { export class AssetProfilesService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly assetProfileSplitService: AssetProfileSplitService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@ -38,6 +42,48 @@ export class AssetProfilesService {
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async createSplit({
dataSource,
date,
denominator,
numerator,
symbol,
symbolProfileId
}: {
date: Date;
denominator: number;
numerator: number;
symbolProfileId: string;
} & AssetProfileIdentifier) {
const assetProfileSplit = await this.assetProfileSplitService.upsert({
date,
denominator,
numerator,
symbolProfileId
});
await this.dataGatheringService.gatherSymbol({ dataSource, symbol });
return assetProfileSplit;
}
public async deleteSplit({
id,
symbolProfileId
}: {
id: string;
symbolProfileId: string;
}) {
const isDeleted = await this.assetProfileSplitService.deleteById({
id,
symbolProfileId
});
if (!isDeleted) {
throw new NotFoundException();
}
}
public async getAssetProfile({ public async getAssetProfile({
dataSource, dataSource,
symbol symbol
@ -54,7 +100,7 @@ export class AssetProfilesService {
await this.activitiesService.getStatisticsByCurrency(currency)); await this.activitiesService.getStatisticsByCurrency(currency));
} }
const [[assetProfile], marketData] = await Promise.all([ const [[assetProfile], marketData, splits] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([ this.symbolProfileService.getSymbolProfiles([
{ {
dataSource, dataSource,
@ -69,7 +115,8 @@ export class AssetProfilesService {
dataSource, dataSource,
symbol symbol
} }
}) }),
this.assetProfileSplitService.getSplits({ dataSource, symbol })
]); ]);
if (assetProfile) { if (assetProfile) {
@ -80,6 +127,7 @@ export class AssetProfilesService {
return { return {
marketData, marketData,
splits,
assetProfile: assetProfile ?? { assetProfile: assetProfile ?? {
activitiesCount, activitiesCount,
currency, currency,
@ -92,6 +140,7 @@ export class AssetProfilesService {
} }
}; };
} }
public async getAssetProfiles({ public async getAssetProfiles({
filters = [], filters = [],
presetId, presetId,

37
apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts

@ -5,17 +5,14 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
DEFAULT_DATE_RANGE,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import type { import type {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetailsResponse, BenchmarkMarketDataDetailsResponse,
BenchmarkResponse BenchmarkResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -37,6 +34,7 @@ import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarksService } from './benchmarks.service'; import { BenchmarksService } from './benchmarks.service';
import { GetBenchmarkMarketDataDto } from './get-benchmark-market-data.dto';
@Controller('benchmarks') @Controller('benchmarks')
export class BenchmarksController { export class BenchmarksController {
@ -121,38 +119,39 @@ export class BenchmarksController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, @Query()
@Query('accounts') filterByAccounts?: string, {
@Query('assetClasses') filterByAssetClasses?: string, accounts,
@Query('dataSource') filterByDataSource?: string, assetClasses,
@Query('symbol') filterBySymbol?: string, dataSource: filterByDataSource,
@Query('tags') filterByTags?: string, range,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false' symbol: filterBySymbol,
tags,
withExcludedAccounts
}: GetBenchmarkMarketDataDto
): Promise<BenchmarkMarketDataDetailsResponse> { ): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange({ const { endDate, startDate } = getIntervalFromDateRange({
dateRange, dateRange: range,
startDate: new Date(startDateString) startDate: new Date(startDateString)
}); });
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource, filterByDataSource,
filterBySymbol, filterBySymbol,
filterByTags filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByTags: tags
}); });
const withExcludedAccounts = withExcludedAccountsParam === 'true';
return this.benchmarksService.getMarketDataForUser({ return this.benchmarksService.getMarketDataForUser({
dataSource, dataSource,
dateRange,
endDate, endDate,
filters, filters,
impersonationId, impersonationId,
startDate, startDate,
symbol, symbol,
withExcludedAccounts, withExcludedAccounts,
dateRange: range,
user: this.request.user user: this.request.user
}); });
} }

12
apps/api/src/app/endpoints/benchmarks/get-benchmark-market-data.dto.ts

@ -0,0 +1,12 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean } from 'class-validator';
export class GetBenchmarkMarketDataDto extends DateRangeFilterDto {
@IsBoolean()
@Transform(({ value }: TransformFnParams) => {
return value === 'true';
})
withExcludedAccounts? = false;
}

32
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -51,7 +51,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -90,12 +90,12 @@ export class GhostfolioController {
@Version('2') @Version('2')
public async getDividends( public async getDividends(
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query() query: GetDividendsDto @Query() { from, granularity, to }: GetDividendsDto
): Promise<DividendsResponse> { ): Promise<DividendsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -105,10 +105,10 @@ export class GhostfolioController {
try { try {
const dividends = await this.ghostfolioService.getDividends({ const dividends = await this.ghostfolioService.getDividends({
granularity,
symbol, symbol,
from: parseDate(query.from), from: parseDate(from),
granularity: query.granularity, to: parseDate(to)
to: parseDate(query.to)
}); });
await this.ghostfolioService.incrementDailyRequests({ await this.ghostfolioService.incrementDailyRequests({
@ -130,12 +130,12 @@ export class GhostfolioController {
@Version('2') @Version('2')
public async getHistorical( public async getHistorical(
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query() query: GetHistoricalDto @Query() { from, granularity, to }: GetHistoricalDto
): Promise<HistoricalResponse> { ): Promise<HistoricalResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -145,10 +145,10 @@ export class GhostfolioController {
try { try {
const historicalData = await this.ghostfolioService.getHistorical({ const historicalData = await this.ghostfolioService.getHistorical({
granularity,
symbol, symbol,
from: parseDate(query.from), from: parseDate(from),
granularity: query.granularity, to: parseDate(to)
to: parseDate(query.to)
}); });
await this.ghostfolioService.incrementDailyRequests({ await this.ghostfolioService.incrementDailyRequests({
@ -176,7 +176,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -215,7 +215,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -247,12 +247,12 @@ export class GhostfolioController {
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2') @Version('2')
public async getQuotes( public async getQuotes(
@Query() query: GetQuotesDto @Query() { symbols }: GetQuotesDto
): Promise<QuotesResponse> { ): Promise<QuotesResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -262,7 +262,7 @@ export class GhostfolioController {
try { try {
const quotes = await this.ghostfolioService.getQuotes({ const quotes = await this.ghostfolioService.getQuotes({
symbols: query.symbols symbols
}); });
await this.ghostfolioService.incrementDailyRequests({ await this.ghostfolioService.incrementDailyRequests({

21
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -17,7 +17,10 @@ import {
DERIVED_CURRENCIES DERIVED_CURRENCIES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import {
getAssetProfileIdentifier,
isValidSearchQuery
} from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioAssetProfileResponse,
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
@ -326,8 +329,12 @@ export class GhostfolioService {
} }
public async incrementDailyRequests({ userId }: { userId: string }) { public async incrementDailyRequests({ userId }: { userId: string }) {
await this.prismaService.analytics.update({ await this.prismaService.analytics.upsert({
data: { create: {
dataProviderGhostfolioDailyRequests: 1,
user: { connect: { id: userId } }
},
update: {
dataProviderGhostfolioDailyRequests: { increment: 1 } dataProviderGhostfolioDailyRequests: { increment: 1 }
}, },
where: { userId } where: { userId }
@ -340,7 +347,9 @@ export class GhostfolioService {
}: GetSearchParams): Promise<LookupResponse> { }: GetSearchParams): Promise<LookupResponse> {
const results: LookupResponse = { items: [] }; const results: LookupResponse = { items: [] };
if (!query) { query = query?.trim();
if (!isValidSearchQuery(query)) {
return results; return results;
} }
@ -348,10 +357,6 @@ export class GhostfolioService {
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
const promises: Promise<{ items: LookupItem[] }>[] = []; const promises: Promise<{ items: LookupItem[] }>[] = [];
if (query?.length < 2) {
return { items: lookupItems };
}
for (const dataProviderService of this.getDataProviderServices()) { for (const dataProviderService of this.getDataProviderServices()) {
promises.push( promises.push(
dataProviderService.search({ dataProviderService.search({

5
apps/api/src/app/endpoints/tags/tags.controller.ts

@ -2,6 +2,7 @@ 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 { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { isSystemTag } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
@ -69,7 +70,7 @@ export class TagsController {
id id
}); });
if (!originalTag) { if (!originalTag || isSystemTag(originalTag)) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -94,7 +95,7 @@ export class TagsController {
id id
}); });
if (!originalTag) { if (!originalTag || isSystemTag(originalTag)) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN

43
apps/api/src/app/export/export.controller.ts

@ -2,6 +2,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { ExportResponse } from '@ghostfolio/common/interfaces'; import { ExportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -15,9 +16,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import { GetExportDto } from './get-export.dto';
@Controller('export') @Controller('export')
export class ExportController { export class ExportController {
@ -32,29 +33,41 @@ export class ExportController {
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query()
@Query('activityIds') filterByActivityIds?: string, {
@Query('activityTypes') filterByTypes?: string, accounts,
@Query('assetClasses') filterByAssetClasses?: string, activityIds,
@Query('dataSource') filterByDataSource?: string, activityTypes,
@Query('symbol') filterBySymbol?: string, assetClasses,
@Query('tags') filterByTags?: string dataSource,
range,
symbol,
tags
}: GetExportDto
): Promise<ExportResponse> { ): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? []; let endDate: Date;
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? []; let startDate: Date;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
return this.exportService.export({ return this.exportService.export({
activityIds, activityIds,
activityTypes, activityTypes,
endDate,
filters, filters,
startDate,
userId: this.request.user.id, userId: this.request.user.id,
userSettings: this.request.user.settings.settings userSettings: this.request.user.settings.settings
}); });

21
apps/api/src/app/export/export.service.ts

@ -25,13 +25,17 @@ export class ExportService {
public async export({ public async export({
activityIds, activityIds,
activityTypes, activityTypes,
endDate,
filters, filters,
startDate,
userId, userId,
userSettings userSettings
}: { }: {
activityIds?: string[]; activityIds?: string[];
activityTypes?: ActivityType[]; activityTypes?: ActivityType[];
endDate?: Date;
filters?: Filter[]; filters?: Filter[];
startDate?: Date;
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
}): Promise<ExportResponse> { }): Promise<ExportResponse> {
@ -41,7 +45,9 @@ export class ExportService {
const platformsMap: { [platformId: string]: Platform } = {}; const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.activitiesService.getActivities({ let { activities } = await this.activitiesService.getActivities({
endDate,
filters, filters,
startDate,
userId, userId,
includeDrafts: true, includeDrafts: true,
sortColumn: 'date', sortColumn: 'date',
@ -67,6 +73,13 @@ export class ExportService {
}; };
} }
const isFilteredExport =
activityIds?.length > 0 ||
activityTypes?.length > 0 ||
filters?.length > 0 ||
!!endDate ||
!!startDate;
const accounts = ( const accounts = (
await this.accountService.accounts({ await this.accountService.accounts({
where, where,
@ -81,7 +94,7 @@ export class ExportService {
}) })
) )
.filter(({ id }) => { .filter(({ id }) => {
return activityIds?.length > 0 return isFilteredExport
? activities.some(({ accountId }) => { ? activities.some(({ accountId }) => {
return accountId === id; return accountId === id;
}) })
@ -89,30 +102,26 @@ export class ExportService {
}) })
.map( .map(
({ ({
balance,
balances, balances,
comment, comment,
currency, currency,
id, id,
isExcluded,
name, name,
platform, platform,
platformId, platformId,
tags tags
}) => { }): ExportResponse['accounts'][number] => {
if (platformId) { if (platformId) {
platformsMap[platformId] = platform; platformsMap[platformId] = platform;
} }
return { return {
balance,
balances: balances.map(({ date, value }) => { balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value }; return { date: date.toISOString(), value };
}), }),
comment, comment,
currency, currency,
id, id,
isExcluded,
name, name,
platformId, platformId,
tags: tags.map(({ id: tagId }) => { tags: tags.map(({ id: tagId }) => {

14
apps/api/src/app/export/get-export.dto.ts

@ -0,0 +1,14 @@
import { ActivitiesFilterDto } from '@ghostfolio/api/app/activities/activities-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsOptional, IsUUID } from 'class-validator';
import { isString } from 'lodash';
export class GetExportDto extends ActivitiesFilterDto {
@IsOptional()
@IsUUID(undefined, { each: true })
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
activityIds?: string[];
}

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

@ -26,7 +26,9 @@ export class HealthService {
public async isDatabaseHealthy() { public async isDatabaseHealthy() {
try { try {
await this.propertyService.getByKey(PROPERTY_CURRENCIES); await this.propertyService.getByKey(PROPERTY_CURRENCIES, {
skipCache: true
});
return true; return true;
} catch { } catch {

7
apps/api/src/app/import/import-data.dto.ts

@ -2,6 +2,7 @@ import {
CreateAccountWithBalancesDto, CreateAccountWithBalancesDto,
CreateAssetProfileWithMarketDataDto, CreateAssetProfileWithMarketDataDto,
CreateOrderDto, CreateOrderDto,
CreatePlatformDto,
CreateTagDto CreateTagDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
@ -26,6 +27,12 @@ export class ImportDataDto {
@ValidateNested({ each: true }) @ValidateNested({ each: true })
assetProfiles?: CreateAssetProfileWithMarketDataDto[]; assetProfiles?: CreateAssetProfileWithMarketDataDto[];
@IsArray()
@IsOptional()
@Type(() => CreatePlatformDto)
@ValidateNested({ each: true })
platforms?: CreatePlatformDto[];
@IsArray() @IsArray()
@IsOptional() @IsOptional()
@Type(() => CreateTagDto) @Type(() => CreateTagDto)

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

@ -77,6 +77,7 @@ export class ImportController {
accountsWithBalancesDto: importData.accounts ?? [], accountsWithBalancesDto: importData.accounts ?? [],
activitiesDto: importData.activities, activitiesDto: importData.activities,
assetProfilesWithMarketDataDto: importData.assetProfiles ?? [], assetProfilesWithMarketDataDto: importData.assetProfiles ?? [],
platformsDto: importData.platforms ?? [],
tagsDto: importData.tags ?? [], tagsDto: importData.tags ?? [],
user: this.request.user user: this.request.user
}); });

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

@ -9,10 +9,16 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
ghostfolioPrefix,
NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos'; import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -174,6 +180,7 @@ export class ImportService {
assetProfilesWithMarketDataDto, assetProfilesWithMarketDataDto,
isDryRun = false, isDryRun = false,
maxActivitiesToImport, maxActivitiesToImport,
platformsDto,
tagsDto, tagsDto,
user user
}: { }: {
@ -182,14 +189,89 @@ export class ImportService {
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles']; assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
isDryRun?: boolean; isDryRun?: boolean;
maxActivitiesToImport: number; maxActivitiesToImport: number;
platformsDto: ImportDataDto['platforms'];
tagsDto: ImportDataDto['tags']; tagsDto: ImportDataDto['tags'];
user: UserWithSettings; user: UserWithSettings;
}): Promise<Activity[]> { }): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {}; const accountIdMapping: { [oldAccountId: string]: string } = {};
const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {}; const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {};
const platformIdMapping: { [oldPlatformId: string]: string } = {};
const tagIdMapping: { [oldTagId: string]: string } = {}; const tagIdMapping: { [oldTagId: string]: string } = {};
const userCurrency = user.settings.settings.baseCurrency; const userCurrency = user.settings.settings.baseCurrency;
// Validate the symbols before any data is persisted
for (const [index, assetProfileWithMarketData] of (
assetProfilesWithMarketDataDto ?? []
).entries()) {
if (
assetProfileWithMarketData.dataSource === DataSource.MANUAL &&
!isValidCustomAssetProfileSymbol(assetProfileWithMarketData.symbol)
) {
throw new Error(
`assetProfiles.${index}.symbol ("${assetProfileWithMarketData.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")`
);
}
}
// Validate the symbols before any data is persisted. Activities without a
// data source are excluded, since a symbol is generated in
// createActivity() if needed.
for (const [index, activity] of activitiesDto.entries()) {
if (!activity.dataSource) {
if (NON_INVESTMENT_ACTIVITY_TYPES.includes(activity.type)) {
activity.dataSource = DataSource.MANUAL;
} else {
activity.dataSource =
this.dataProviderService.getDataSourceForImport();
}
} else if (
activity.dataSource === DataSource.MANUAL &&
!isValidCustomAssetProfileSymbol(activity.symbol)
) {
throw new Error(
`activities.${index}.symbol ("${activity.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")`
);
}
}
if (platformsDto?.length) {
const canCreatePlatform = hasPermission(
user.permissions,
permissions.createPlatform
);
const existingPlatforms = await this.platformService.getPlatforms();
for (const platform of platformsDto) {
// Check if there is any existing platform with the same ID, otherwise
// fall back to a platform with the same URL
const existingPlatform =
existingPlatforms.find(({ id }) => {
return id === platform.id;
}) ??
existingPlatforms.find(({ url }) => {
return url === platform.url;
});
if (existingPlatform) {
// Store the new to old platform ID mappings for creating accounts
if (platform.id && existingPlatform.id !== platform.id) {
platformIdMapping[platform.id] = existingPlatform.id;
}
} else {
if (!canCreatePlatform) {
throw new Error(
`Insufficient permissions to create platform ("${platform.name}")`
);
}
if (!isDryRun) {
await this.platformService.createPlatform(platform);
}
}
}
}
const existingTagsOfUser = const existingTagsOfUser =
tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length) tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length)
? await this.tagService.getTagsForUser(user.id) ? await this.tagService.getTagsForUser(user.id)
@ -272,10 +354,16 @@ export class ImportService {
// If there is no account or if the account belongs to a different user then create a new account // If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== user.id) { if (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account = omit(accountWithBalances, ['balances', 'tags']); const account = omit(accountWithBalances, [
'balance',
'balances',
'isExcluded',
'tags'
]);
let oldAccountId: string; let oldAccountId: string;
const platformId = account.platformId; const platformId =
platformIdMapping[account.platformId] ?? account.platformId;
delete account.platformId; delete account.platformId;
@ -292,6 +380,16 @@ export class ImportService {
return existingTagIds.has(tagId); return existingTagIds.has(tagId);
}); });
// Map the legacy isExcluded attribute of old export files to
// the "Exclude from Analysis" tag
if (
accountWithBalances.isExcluded &&
existingTagIds.has(TAG_ID_EXCLUDE_FROM_ANALYSIS) &&
!tagIds.includes(TAG_ID_EXCLUDE_FROM_ANALYSIS)
) {
tagIds.push(TAG_ID_EXCLUDE_FROM_ANALYSIS);
}
let accountObject: Prisma.AccountCreateInput = { let accountObject: Prisma.AccountCreateInput = {
...account, ...account,
balances: { balances: {
@ -311,11 +409,12 @@ export class ImportService {
}; };
} }
const newAccount = await this.accountService.createAccount( const newAccount = await this.accountService.createAccount({
accountObject, tagIds,
user.id, balance: accountWithBalances.balance,
tagIds data: accountObject,
); userId: user.id
});
// Store the new to old account ID mappings for updating activities // Store the new to old account ID mappings for updating activities
if (accountWithSameId && oldAccountId) { if (accountWithSameId && oldAccountId) {
@ -325,71 +424,117 @@ export class ImportService {
} }
} }
if (!isDryRun && assetProfilesWithMarketDataDto?.length) { if (assetProfilesWithMarketDataDto?.length) {
const existingAssetProfiles = const customAssetProfileNames = assetProfilesWithMarketDataDto
await this.symbolProfileService.getSymbolProfiles( .filter(({ dataSource, name }) => {
assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => { return dataSource === DataSource.MANUAL && Boolean(name);
return { dataSource, symbol }; })
.map(({ name }) => {
return name;
});
const [existingAssetProfiles, existingCustomAssetProfilesOfUser] =
await Promise.all([
this.symbolProfileService.getSymbolProfiles(
assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
),
this.symbolProfileService.getCustomSymbolProfilesByNames({
names: customAssetProfileNames,
userId: user.id
}) })
); ]);
for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) {
let symbol = assetProfileWithMarketData.symbol;
// Check if there is any existing asset profile // Check if there is any existing asset profile
const existingAssetProfile = existingAssetProfiles.find( const existingAssetProfile = existingAssetProfiles.find(
({ dataSource, symbol }) => { (assetProfile) => {
return ( return (
dataSource === assetProfileWithMarketData.dataSource && assetProfile.dataSource ===
symbol === assetProfileWithMarketData.symbol assetProfileWithMarketData.dataSource &&
assetProfile.symbol === assetProfileWithMarketData.symbol
); );
} }
); );
// If there is no asset profile or if the asset profile belongs to a different user, then create a new asset profile // If there is no asset profile or if the asset profile belongs to a
// different user, then reuse the custom asset profile of the user or
// create a new asset profile
if (!existingAssetProfile || existingAssetProfile.userId !== user.id) { if (!existingAssetProfile || existingAssetProfile.userId !== user.id) {
const assetProfile: CreateAssetProfileDto = omit( // Check if the user has a custom asset profile with the same name.
assetProfileWithMarketData, // Skip asset profiles with a legacy free-text symbol as they would
'marketData' // fail the symbol validation on a future import.
); const existingCustomAssetProfileOfUser =
assetProfileWithMarketData.dataSource === DataSource.MANUAL
? existingCustomAssetProfilesOfUser.find((customAssetProfile) => {
return (
customAssetProfile.name ===
assetProfileWithMarketData.name &&
isValidCustomAssetProfileSymbol(customAssetProfile.symbol)
);
})
: undefined;
if (existingCustomAssetProfileOfUser) {
// Reuse the custom asset profile of the user instead of creating a duplicate
symbol = existingCustomAssetProfileOfUser.symbol;
} else {
const assetProfile: CreateAssetProfileDto = omit(
assetProfileWithMarketData,
'marketData'
);
// Asset profile belongs to a different user, generate a new symbol
if (existingAssetProfile && !isDryRun) {
symbol = randomUUID();
}
// Asset profile belongs to a different user
if (existingAssetProfile) {
const symbol = randomUUID();
assetProfileSymbolMapping[assetProfile.symbol] = symbol;
assetProfile.symbol = symbol; assetProfile.symbol = symbol;
if (!isDryRun) {
// Create a new asset profile
const assetProfileObject: Prisma.SymbolProfileCreateInput = {
...assetProfile,
user: { connect: { id: user.id } }
};
await this.symbolProfileService.add(assetProfileObject);
}
} }
// Create a new asset profile if (symbol !== assetProfileWithMarketData.symbol) {
const assetProfileObject: Prisma.SymbolProfileCreateInput = { assetProfileSymbolMapping[assetProfileWithMarketData.symbol] =
...assetProfile, symbol;
user: { connect: { id: user.id } }
};
await this.symbolProfileService.add(assetProfileObject); // Keep the asset profile in sync with the activities to validate
assetProfileWithMarketData.symbol = symbol;
}
} }
// Insert or update market data if (!isDryRun) {
const marketDataObjects = assetProfileWithMarketData.marketData.map( // Insert or update market data
(marketData) => { const marketDataObjects = (
assetProfileWithMarketData.marketData ?? []
).map((marketData) => {
return { return {
...marketData, ...marketData,
dataSource: assetProfileWithMarketData.dataSource, symbol,
symbol: assetProfileWithMarketData.symbol dataSource: assetProfileWithMarketData.dataSource
} as Prisma.MarketDataUpdateInput; } as Prisma.MarketDataUpdateInput;
} });
);
await this.marketDataService.updateMany({ data: marketDataObjects }); await this.marketDataService.updateMany({ data: marketDataObjects });
}
} }
} }
for (const activity of activitiesDto) { for (const activity of activitiesDto) {
if (!activity.dataSource) { // If an asset profile is created or reused, then update the symbol in all activities
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { if (assetProfileSymbolMapping[activity.symbol]) {
activity.dataSource = DataSource.MANUAL; activity.symbol = assetProfileSymbolMapping[activity.symbol];
} else {
activity.dataSource =
this.dataProviderService.getDataSourceForImport();
}
} }
if (!isDryRun) { if (!isDryRun) {
@ -398,11 +543,6 @@ export class ImportService {
activity.accountId = accountIdMapping[activity.accountId]; activity.accountId = accountIdMapping[activity.accountId];
} }
// If a new asset profile is created, then update the symbol in all activities
if (assetProfileSymbolMapping[activity.symbol]) {
activity.symbol = assetProfileSymbolMapping[activity.symbol];
}
// If a new tag is created, then update the tag ID in all activities // If a new tag is created, then update the tag ID in all activities
activity.tags = (activity.tags ?? []).map((tagId) => { activity.tags = (activity.tags ?? []).map((tagId) => {
return tagIdMapping[tagId] ?? tagId; return tagIdMapping[tagId] ?? tagId;

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

@ -1,4 +1,5 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error';
import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-position.interface'; import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-position.interface';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
@ -65,6 +66,8 @@ import { isNumber, sortBy, sum, uniqBy } from 'lodash';
export abstract class PortfolioCalculator { export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false; protected static readonly ENABLE_LOGGING = false;
private static readonly MAX_INITIALIZATION_ATTEMPTS = 3;
protected readonly logger = new Logger(PortfolioCalculator.name); protected readonly logger = new Logger(PortfolioCalculator.name);
protected accountBalanceItems: HistoricalDataItem[]; protected accountBalanceItems: HistoricalDataItem[];
@ -174,6 +177,11 @@ export abstract class PortfolioCalculator {
this.computeTransactionPoints(); this.computeTransactionPoints();
this.snapshotPromise = this.initialize(); this.snapshotPromise = this.initialize();
// Mark the rejection as handled to prevent an unhandled promise rejection
// in case the snapshot promise is never awaited. Consumers awaiting it
// still receive the error.
this.snapshotPromise.catch(() => undefined);
} }
protected abstract calculateOverallPerformance( protected abstract calculateOverallPerformance(
@ -1124,7 +1132,7 @@ export abstract class PortfolioCalculator {
} }
@LogPerformance @LogPerformance
private async initialize() { private async initialize(attempt = 1) {
const startTimeTotal = performance.now(); const startTimeTotal = performance.now();
let cachedPortfolioSnapshot: PortfolioSnapshot; let cachedPortfolioSnapshot: PortfolioSnapshot;
@ -1183,6 +1191,12 @@ export abstract class PortfolioCalculator {
}); });
} }
} else { } else {
if (attempt > PortfolioCalculator.MAX_INITIALIZATION_ATTEMPTS) {
throw new PortfolioSnapshotComputationError(
`Portfolio snapshot of user '${this.userId}' could not be computed after ${PortfolioCalculator.MAX_INITIALIZATION_ATTEMPTS} attempts`
);
}
// Wait for computation // Wait for computation
await this.portfolioSnapshotService.addJobToQueue({ await this.portfolioSnapshotService.addJobToQueue({
data: { data: {
@ -1205,7 +1219,7 @@ export abstract class PortfolioCalculator {
await job.finished(); await job.finished();
} }
await this.initialize(); await this.initialize(attempt + 1);
} }
} }
} }

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

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

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

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

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts

@ -76,6 +76,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

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

@ -66,6 +66,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts

@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

14
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -77,6 +77,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -166,6 +169,14 @@ describe('PortfolioCalculator', () => {
id: randomUUID(), id: randomUUID(),
value: 2000, value: 2000,
valueInBaseCurrency: 1800 valueInBaseCurrency: 1800
},
{
// Ignored future account balance
accountId,
date: parseDate('2050-12-31'),
id: randomUUID(),
value: 0,
valueInBaseCurrency: 0
} }
] ]
}); });
@ -178,7 +189,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'), createdAt: parseDate('2023-12-31'),
currency: 'USD', currency: 'USD',
id: accountId, id: accountId,
isExcluded: false,
name: 'USD', name: 'USD',
platformId: null, platformId: null,
updatedAt: parseDate('2023-12-31'), updatedAt: parseDate('2023-12-31'),
@ -354,7 +364,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'), createdAt: parseDate('2023-12-31'),
currency: 'CHF', currency: 'CHF',
id: accountId, id: accountId,
isExcluded: false,
name: 'CHF', name: 'CHF',
platformId: null, platformId: null,
updatedAt: parseDate('2023-12-31'), updatedAt: parseDate('2023-12-31'),
@ -486,7 +495,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'), createdAt: parseDate('2023-12-31'),
currency: 'CHF', currency: 'CHF',
id: accountId, id: accountId,
isExcluded: false,
name: 'CHF', name: 'CHF',
platformId: null, platformId: null,
updatedAt: parseDate('2023-12-31'), updatedAt: parseDate('2023-12-31'),

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

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

@ -66,6 +66,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts

@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts

@ -52,6 +52,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts

@ -49,6 +49,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

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

@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

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

@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

7
apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts

@ -0,0 +1,7 @@
export class PortfolioSnapshotComputationError extends Error {
public constructor(message: string) {
super(message);
this.name = 'PortfolioSnapshotComputationError';
}
}

12
apps/api/src/app/portfolio/get-details.dto.ts

@ -0,0 +1,12 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean } from 'class-validator';
export class GetDetailsDto extends DateRangeFilterDto {
@IsBoolean()
@Transform(({ value }: TransformFnParams) => {
return value === 'true';
})
withMarkets? = false;
}

10
apps/api/src/app/portfolio/get-dividends.dto.ts

@ -0,0 +1,10 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { GroupBy } from '@ghostfolio/common/types';
import { IsIn, IsOptional } from 'class-validator';
export class GetDividendsDto extends DateRangeFilterDto {
@IsIn(['month', 'year'] as GroupBy[])
@IsOptional()
groupBy?: GroupBy;
}

14
apps/api/src/app/portfolio/get-holdings.dto.ts

@ -0,0 +1,14 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { HoldingType } from '@ghostfolio/common/types';
import { IsIn, IsOptional, IsString } from 'class-validator';
export class GetHoldingsDto extends DateRangeFilterDto {
@IsIn(['ACTIVE', 'CLOSED'] as HoldingType[])
@IsOptional()
holdingType?: HoldingType;
@IsOptional()
@IsString()
query?: string;
}

10
apps/api/src/app/portfolio/get-investments.dto.ts

@ -0,0 +1,10 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { GroupBy } from '@ghostfolio/common/types';
import { IsIn, IsOptional } from 'class-validator';
export class GetInvestmentsDto extends DateRangeFilterDto {
@IsIn(['month', 'year'] as GroupBy[])
@IsOptional()
groupBy?: GroupBy;
}

12
apps/api/src/app/portfolio/get-performance.dto.ts

@ -0,0 +1,12 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean } from 'class-validator';
export class GetPerformanceDto extends DateRangeFilterDto {
@IsBoolean()
@Transform(({ value }: TransformFnParams) => {
return value === 'true';
})
withExcludedAccounts? = false;
}

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

@ -15,7 +15,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
DEFAULT_DATE_RANGE,
HEADER_KEY_IMPERSONATION, HEADER_KEY_IMPERSONATION,
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -34,11 +33,7 @@ import {
isRestrictedView, isRestrictedView,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import type { import type { RequestWithUser } from '@ghostfolio/common/types';
DateRange,
GroupBy,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -60,6 +55,11 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { GetDetailsDto } from './get-details.dto';
import { GetDividendsDto } from './get-dividends.dto';
import { GetHoldingsDto } from './get-holdings.dto';
import { GetInvestmentsDto } from './get-investments.dto';
import { GetPerformanceDto } from './get-performance.dto';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto'; import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@ -82,16 +82,17 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('assetClasses') filterByAssetClasses?: string, {
@Query('dataSource') filterByDataSource?: string, accounts: filterByAccounts,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, assetClasses: filterByAssetClasses,
@Query('symbol') filterBySymbol?: string, dataSource: filterByDataSource,
@Query('tags') filterByTags?: string, range,
@Query('withMarkets') withMarketsParam = 'false' symbol: filterBySymbol,
tags: filterByTags,
withMarkets
}: GetDetailsDto
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
const withMarkets = withMarketsParam === 'true';
let hasDetails = true; let hasDetails = true;
let hasError = false; let hasError = false;
@ -118,10 +119,10 @@ export class PortfolioController {
platforms, platforms,
summary summary
} = await this.portfolioService.getDetails({ } = await this.portfolioService.getDetails({
dateRange,
filters, filters,
impersonationId, impersonationId,
withMarkets, withMarkets,
dateRange: range,
userId: this.request.user.id, userId: this.request.user.id,
withSummary: true withSummary: true
}); });
@ -135,7 +136,7 @@ export class PortfolioController {
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -179,7 +180,7 @@ export class PortfolioController {
hasDetails === false || hasDetails === false ||
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -326,20 +327,23 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getDividends( public async getDividends(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('assetClasses') filterByAssetClasses?: string, {
@Query('dataSource') filterByDataSource?: string, accounts,
@Query('groupBy') groupBy?: GroupBy, assetClasses,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, dataSource,
@Query('symbol') filterBySymbol?: string, groupBy,
@Query('tags') filterByTags?: string range,
symbol,
tags
}: GetDividendsDto
): Promise<PortfolioDividendsResponse> { ): Promise<PortfolioDividendsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
const impersonationUserId = const impersonationUserId =
@ -349,7 +353,9 @@ export class PortfolioController {
const { settings } = await this.userService.user({ id: userId }); const { settings } = await this.userService.user({ id: userId });
const userCurrency = settings.settings.baseCurrency; const userCurrency = settings.settings.baseCurrency;
const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); const { endDate, startDate } = getIntervalFromDateRange({
dateRange: range
});
const { activities } = await this.activitiesService.getActivities({ const { activities } = await this.activitiesService.getActivities({
endDate, endDate,
@ -368,7 +374,7 @@ export class PortfolioController {
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -429,29 +435,32 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getHoldings( public async getHoldings(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('assetClasses') filterByAssetClasses?: string, {
@Query('dataSource') filterByDataSource?: string, accounts,
@Query('holdingType') filterByHoldingType?: string, assetClasses,
@Query('query') filterBySearchQuery?: string, dataSource,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, holdingType,
@Query('symbol') filterBySymbol?: string, query,
@Query('tags') filterByTags?: string range,
symbol,
tags
}: GetHoldingsDto
): Promise<PortfolioHoldingsResponse> { ): Promise<PortfolioHoldingsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterByHoldingType, filterByHoldingType: holdingType,
filterBySearchQuery, filterBySearchQuery: query,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
const holdings = await this.portfolioService.getHoldings({ const holdings = await this.portfolioService.getHoldings({
dateRange,
filters, filters,
impersonationId, impersonationId,
dateRange: range,
userId: this.request.user.id userId: this.request.user.id
}); });
@ -463,35 +472,38 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getInvestments( public async getInvestments(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('assetClasses') filterByAssetClasses?: string, {
@Query('dataSource') filterByDataSource?: string, accounts,
@Query('groupBy') groupBy?: GroupBy, assetClasses,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, dataSource,
@Query('symbol') filterBySymbol?: string, groupBy,
@Query('tags') filterByTags?: string range,
symbol,
tags
}: GetInvestmentsDto
): Promise<PortfolioInvestmentsResponse> { ): Promise<PortfolioInvestmentsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
let { investments, streaks } = await this.portfolioService.getInvestments({ let { investments, savingsRate, streaks } =
dateRange, await this.portfolioService.getInvestments({
filters, filters,
groupBy, groupBy,
impersonationId, impersonationId,
savingsRate: this.request.user?.settings?.settings.savingsRate, dateRange: range,
userId: this.request.user.id userId: this.request.user.id
}); });
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -509,6 +521,8 @@ export class PortfolioController {
'currentStreak', 'currentStreak',
'longestStreak' 'longestStreak'
]); ]);
savingsRate = null;
} }
if ( if (
@ -525,7 +539,7 @@ export class PortfolioController {
]); ]);
} }
return { investments, streaks }; return { investments, savingsRate, streaks };
} }
@Get('performance') @Get('performance')
@ -536,36 +550,37 @@ export class PortfolioController {
@Version('2') @Version('2')
public async getPerformanceV2( public async getPerformanceV2(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('assetClasses') filterByAssetClasses?: string, {
@Query('dataSource') filterByDataSource?: string, accounts,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, assetClasses,
@Query('symbol') filterBySymbol?: string, dataSource,
@Query('tags') filterByTags?: string, range,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false' symbol,
tags,
withExcludedAccounts
}: GetPerformanceDto
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const withExcludedAccounts = withExcludedAccountsParam === 'true';
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
const performanceInformation = await this.portfolioService.getPerformance({ const performanceInformation = await this.portfolioService.getPerformance({
dateRange,
filters, filters,
impersonationId, impersonationId,
withExcludedAccounts, withExcludedAccounts,
dateRange: range,
userId: this.request.user.id userId: this.request.user.id
}); });
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) || isRestrictedView(this.request.user) ||
this.request.user.settings.settings.viewMode === 'ZEN' this.request.user.settings.settings.viewMode === 'ZEN'

97
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -1,6 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
@ -11,9 +12,13 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation/imp
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
import { AccountWithBalance } from '@ghostfolio/common/types';
import { Account, DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -92,6 +97,7 @@ describe('PortfolioService', () => {
null, null,
null, null,
null, null,
null,
null null
); );
@ -187,7 +193,6 @@ describe('PortfolioService', () => {
createdAt: parseDate('2024-01-01'), createdAt: parseDate('2024-01-01'),
currency: 'USD', currency: 'USD',
id: randomUUID(), id: randomUUID(),
isExcluded: false,
name: 'USD', name: 'USD',
platformId: null, platformId: null,
updatedAt: parseDate('2024-01-01'), updatedAt: parseDate('2024-01-01'),
@ -215,13 +220,12 @@ describe('PortfolioService', () => {
it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => { it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => {
const accountId = randomUUID(); const accountId = randomUUID();
const cashAccount: Account = { const cashAccount: AccountWithBalance = {
balance: 2000, balance: 2000,
comment: null, comment: null,
createdAt: parseDate('2024-01-01'), createdAt: parseDate('2024-01-01'),
currency: 'USD', currency: 'USD',
id: accountId, id: accountId,
isExcluded: false,
name: 'USD', name: 'USD',
platformId: null, platformId: null,
updatedAt: parseDate('2024-01-01'), updatedAt: parseDate('2024-01-01'),
@ -337,6 +341,86 @@ describe('PortfolioService', () => {
}); });
}); });
describe('getSummary', () => {
const getSummary = (args: object) => {
return (
portfolioService as unknown as {
getSummary: (aArgs: object) => Promise<PortfolioSummary>;
}
).getSummary(args);
};
function createPortfolioCalculator() {
return {
getDividendInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getFeesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getInterestInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getLiabilitiesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getSnapshot: jest.fn().mockResolvedValue({
currentValueInBaseCurrency: new Big(3000),
totalCashInBaseCurrency: new Big(1000),
totalInvestment: new Big(2000),
totalInvestmentWithCurrencyEffect: new Big(2000)
}),
getStartDate: jest.fn().mockReturnValue(parseDate('2024-01-01'))
} as unknown as PortfolioCalculator;
}
beforeEach(() => {
jest
.spyOn(activitiesService, 'getActivities')
.mockResolvedValue({ activities: [], count: 0 });
jest
.spyOn(impersonationService, 'validateImpersonationId')
.mockResolvedValue(null);
jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({
performance: {
currentValueInBaseCurrency: 3000,
netPerformance: 500,
netPerformancePercentage: 0.2,
netPerformancePercentageWithCurrencyEffect: 0.2,
netPerformanceWithCurrencyEffect: 500
}
} as Awaited<ReturnType<typeof portfolioService.getPerformance>>);
jest.spyOn(userService, 'user').mockResolvedValue({
id: userDummyData.id,
settings: {
settings: {
baseCurrency: 'CHF'
}
}
} as unknown as Awaited<ReturnType<typeof userService.user>>);
});
it('should derive the cash and net worth from the account balance when there are no excluded accounts, no emergency fund and no liabilities', async () => {
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [],
balanceInBaseCurrency: 1000
});
const portfolioCalculator = createPortfolioCalculator();
const summary = await getSummary({
portfolioCalculator,
balanceInBaseCurrency: 1000,
emergencyFundHoldingsValueInBaseCurrency: 0,
filteredValueInBaseCurrency: new Big(3000),
impersonationId: undefined,
userCurrency: 'CHF',
userId: userDummyData.id
});
expect(summary.cash).toBe(1000);
expect(summary.emergencyFund.total).toBe(0);
expect(summary.excludedAccountsAndActivities).toBe(0);
expect(summary.totalAssetsInBaseCurrency).toBe(3000);
expect(summary.totalValueInBaseCurrency).toBe(3000);
});
});
describe('getValueOfAccountsAndPlatforms', () => { describe('getValueOfAccountsAndPlatforms', () => {
const getValueOfAccountsAndPlatforms = (args: object) => { const getValueOfAccountsAndPlatforms = (args: object) => {
return ( return (
@ -353,7 +437,6 @@ describe('PortfolioService', () => {
balance: 100, balance: 100,
currency: 'USD', currency: 'USD',
id: randomUUID(), id: randomUUID(),
isExcluded: false,
name: 'Account 1', name: 'Account 1',
platform: { name: 'Platform 1' }, platform: { name: 'Platform 1' },
platformId: randomUUID() platformId: randomUUID()
@ -362,7 +445,7 @@ describe('PortfolioService', () => {
beforeEach(() => { beforeEach(() => {
jest jest
.spyOn(accountService, 'getAccounts') .spyOn(accountService, 'getAccounts')
.mockResolvedValue([account] as unknown as Account[]); .mockResolvedValue([account] as unknown as AccountWithBalance[]);
jest jest
.spyOn(exchangeRateDataService, 'toCurrency') .spyOn(exchangeRateDataService, 'toCurrency')

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

@ -64,6 +64,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models'; import { TimelinePosition } from '@ghostfolio/common/models';
import { import {
AccountWithBalance,
AccountWithValue, AccountWithValue,
DateRange, DateRange,
GroupBy, GroupBy,
@ -75,7 +76,6 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { import {
Account,
Type as ActivityType, Type as ActivityType,
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -165,6 +165,10 @@ export class PortfolioService {
}; };
} }
const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => {
return type !== 'SEARCH_QUERY';
});
const [accounts, details, user] = await Promise.all([ const [accounts, details, user] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
where, where,
@ -176,8 +180,8 @@ export class PortfolioService {
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getDetails({ this.getDetails({
filters,
withExcludedAccounts, withExcludedAccounts,
filters: filtersWithoutSearchQueryFilter,
impersonationId: userId, impersonationId: userId,
userId: this.request.user.id userId: this.request.user.id
}), }),
@ -369,14 +373,6 @@ export class PortfolioService {
userId: string; userId: string;
}) { }) {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const { holdings: holdingsMap } = await this.getDetails({
dateRange,
filters,
impersonationId,
userId
});
let holdings = Object.values(holdingsMap);
const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy( const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy(
filters, filters,
@ -385,9 +381,22 @@ export class PortfolioService {
} }
); );
const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => {
return type !== 'SEARCH_QUERY';
});
const { holdings: holdingsMap } = await this.getDetails({
dateRange,
impersonationId,
userId,
filters: filtersWithoutSearchQueryFilter
});
let holdings = Object.values(holdingsMap);
if (filterBySearchQuery) { if (filterBySearchQuery) {
const fuse = new Fuse(holdings, { const fuse = new Fuse(holdings, {
keys: ['isin', 'name', 'symbol'], keys: ['assetProfile.isin', 'assetProfile.name', 'assetProfile.symbol'],
threshold: 0.3 threshold: 0.3
}); });
@ -404,19 +413,18 @@ export class PortfolioService {
filters, filters,
groupBy, groupBy,
impersonationId, impersonationId,
savingsRate,
userId userId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
groupBy?: GroupBy; groupBy?: GroupBy;
impersonationId: string; impersonationId: string;
savingsRate: number;
userId: string; userId: string;
}): Promise<PortfolioInvestmentsResponse> { }): Promise<PortfolioInvestmentsResponse> {
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 });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate;
const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
@ -429,6 +437,7 @@ export class PortfolioService {
if (activities.length === 0) { if (activities.length === 0) {
return { return {
savingsRate,
investments: [], investments: [],
streaks: { currentStreak: 0, longestStreak: 0 } streaks: { currentStreak: 0, longestStreak: 0 }
}; };
@ -475,6 +484,7 @@ export class PortfolioService {
return { return {
investments, investments,
savingsRate,
streaks streaks
}; };
} }
@ -651,6 +661,7 @@ export class PortfolioService {
}; };
} }
), ),
isin: assetProfile.isin,
name: assetProfile.name, name: assetProfile.name,
sectors: assetProfile.sectors, sectors: assetProfile.sectors,
symbol: assetProfile.symbol, symbol: assetProfile.symbol,
@ -1035,7 +1046,6 @@ export class PortfolioService {
return { return {
chart: [], chart: [],
dateOfFirstActivity: undefined, dateOfFirstActivity: undefined,
firstOrderDate: undefined,
hasErrors: false, hasErrors: false,
performance: { performance: {
currentNetWorth: 0, currentNetWorth: 0,
@ -1093,7 +1103,6 @@ export class PortfolioService {
errors, errors,
hasErrors, hasErrors,
dateOfFirstActivity: parseDate(historicalData[0]?.date), dateOfFirstActivity: parseDate(historicalData[0]?.date),
firstOrderDate: parseDate(historicalData[0]?.date),
performance: { performance: {
netPerformance, netPerformance,
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
@ -1126,6 +1135,8 @@ export class PortfolioService {
withSummary: true withSummary: true
}); });
const hasOpenHoldings = Object.keys(holdings).length > 0;
const marketsAdvancedTotalInBaseCurrency = getSum( const marketsAdvancedTotalInBaseCurrency = getSum(
Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => { Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency); return new Big(valueInBaseCurrency);
@ -1185,26 +1196,25 @@ export class PortfolioService {
id: 'rule.currencyClusterRisk.category', id: 'rule.currencyClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0 ? await this.rulesService.evaluate(
? await this.rulesService.evaluate( [
[ new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
new CurrencyClusterRiskBaseCurrencyCurrentInvestment( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, Object.values(holdings),
Object.values(holdings), userSettings.language
userSettings.language ),
), new CurrencyClusterRiskCurrentInvestment(
new CurrencyClusterRiskCurrentInvestment( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, Object.values(holdings),
Object.values(holdings), userSettings.language
userSettings.language )
) ],
], userSettings
userSettings )
) : undefined
: undefined
}, },
{ {
key: 'assetClassClusterRisk', key: 'assetClassClusterRisk',
@ -1212,26 +1222,25 @@ export class PortfolioService {
id: 'rule.assetClassClusterRisk.category', id: 'rule.assetClassClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0 ? await this.rulesService.evaluate(
? await this.rulesService.evaluate( [
[ new AssetClassClusterRiskEquity(
new AssetClassClusterRiskEquity( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, userSettings.language,
userSettings.language, Object.values(holdings)
Object.values(holdings) ),
), new AssetClassClusterRiskFixedIncome(
new AssetClassClusterRiskFixedIncome( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, userSettings.language,
userSettings.language, Object.values(holdings)
Object.values(holdings) )
) ],
], userSettings
userSettings )
) : undefined
: undefined
}, },
{ {
key: 'accountClusterRisk', key: 'accountClusterRisk',
@ -1266,28 +1275,27 @@ export class PortfolioService {
id: 'rule.economicMarketClusterRisk.category', id: 'rule.economicMarketClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0 ? await this.rulesService.evaluate(
? await this.rulesService.evaluate( [
[ new EconomicMarketClusterRiskDevelopedMarkets(
new EconomicMarketClusterRiskDevelopedMarkets( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, marketsTotalInBaseCurrency,
marketsTotalInBaseCurrency, markets.developedMarkets.valueInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency, userSettings.language
userSettings.language ),
), new EconomicMarketClusterRiskEmergingMarkets(
new EconomicMarketClusterRiskEmergingMarkets( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, marketsTotalInBaseCurrency,
marketsTotalInBaseCurrency, markets.emergingMarkets.valueInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency, userSettings.language
userSettings.language )
) ],
], userSettings
userSettings )
) : undefined
: undefined
}, },
{ {
key: 'regionalMarketClusterRisk', key: 'regionalMarketClusterRisk',
@ -1295,49 +1303,48 @@ export class PortfolioService {
id: 'rule.regionalMarketClusterRisk.category', id: 'rule.regionalMarketClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0 ? await this.rulesService.evaluate(
? await this.rulesService.evaluate( [
[ new RegionalMarketClusterRiskAsiaPacific(
new RegionalMarketClusterRiskAsiaPacific( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, userSettings.language,
userSettings.language, marketsAdvancedTotalInBaseCurrency,
marketsAdvancedTotalInBaseCurrency, marketsAdvanced.asiaPacific.valueInBaseCurrency
marketsAdvanced.asiaPacific.valueInBaseCurrency ),
), new RegionalMarketClusterRiskEmergingMarkets(
new RegionalMarketClusterRiskEmergingMarkets( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, userSettings.language,
userSettings.language, marketsAdvancedTotalInBaseCurrency,
marketsAdvancedTotalInBaseCurrency, marketsAdvanced.emergingMarkets.valueInBaseCurrency
marketsAdvanced.emergingMarkets.valueInBaseCurrency ),
), new RegionalMarketClusterRiskEurope(
new RegionalMarketClusterRiskEurope( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, userSettings.language,
userSettings.language, marketsAdvancedTotalInBaseCurrency,
marketsAdvancedTotalInBaseCurrency, marketsAdvanced.europe.valueInBaseCurrency
marketsAdvanced.europe.valueInBaseCurrency ),
), new RegionalMarketClusterRiskJapan(
new RegionalMarketClusterRiskJapan( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, userSettings.language,
userSettings.language, marketsAdvancedTotalInBaseCurrency,
marketsAdvancedTotalInBaseCurrency, marketsAdvanced.japan.valueInBaseCurrency
marketsAdvanced.japan.valueInBaseCurrency ),
), new RegionalMarketClusterRiskNorthAmerica(
new RegionalMarketClusterRiskNorthAmerica( this.exchangeRateDataService,
this.exchangeRateDataService, this.i18nService,
this.i18nService, userSettings.language,
userSettings.language, marketsAdvancedTotalInBaseCurrency,
marketsAdvancedTotalInBaseCurrency, marketsAdvanced.northAmerica.valueInBaseCurrency
marketsAdvanced.northAmerica.valueInBaseCurrency )
) ],
], userSettings
userSettings )
) : undefined
: undefined
}, },
{ {
key: 'fees', key: 'fees',
@ -2136,7 +2143,7 @@ export class PortfolioService {
const accounts: PortfolioDetails['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
const platforms: PortfolioDetails['platforms'] = {}; const platforms: PortfolioDetails['platforms'] = {};
let currentAccounts: (Account & { let currentAccounts: (AccountWithBalance & {
Order?: Order[]; Order?: Order[];
platform?: Platform; platform?: Platform;
tags?: Tag[]; tags?: Tag[];

3
apps/api/src/app/redis-cache/redis-cache.service.mock.ts

@ -18,6 +18,9 @@ export const RedisCacheServiceMock = {
return `portfolio-snapshot-${userId}${filtersHash > 0 ? `-${filtersHash}` : ''}`; return `portfolio-snapshot-${userId}${filtersHash > 0 ? `-${filtersHash}` : ''}`;
}, },
reset: () => {
RedisCacheServiceMock.cache.clear();
},
set: (key: string, value: string): Promise<string> => { set: (key: string, value: string): Promise<string> => {
RedisCacheServiceMock.cache.set(key, value); RedisCacheServiceMock.cache.set(key, value);

4
apps/api/src/app/subscription/subscription.controller.ts

@ -54,7 +54,9 @@ export class SubscriptionController {
} }
let coupons = let coupons =
(await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS)) ?? []; (await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS, {
skipCache: true
})) ?? [];
const coupon = coupons.find((currentCoupon) => { const coupon = coupons.find((currentCoupon) => {
return currentCoupon.code === couponCode; return currentCoupon.code === couponCode;

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

@ -1,7 +1,9 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -119,6 +121,7 @@ export class UserController {
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string, @Headers('accept-language') acceptLanguage: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
@ -165,6 +168,7 @@ export class UserController {
@Put('setting') @Put('setting')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async updateUserSetting(@Body() data: UpdateUserSettingDto) { public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if ( if (
size(data) === 1 && size(data) === 1 &&
@ -192,6 +196,12 @@ export class UserController {
data data
); );
if (userSettings['filters.dataSource']) {
userSettings['filters.dataSource'] = decodeDataSource(
userSettings['filters.dataSource']
);
}
for (const key in userSettings) { for (const key in userSettings) {
if (userSettings[key] === false || userSettings[key] === null) { if (userSettings[key] === false || userSettings[key] === null) {
delete userSettings[key]; delete userSettings[key];

4
apps/api/src/app/user/user.module.ts

@ -1,6 +1,7 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
@ -30,7 +31,8 @@ import { UserService } from './user.service';
PropertyModule, PropertyModule,
RedactValuesInResponseModule, RedactValuesInResponseModule,
SubscriptionModule, SubscriptionModule,
TagModule TagModule,
TransformDataSourceInResponseModule
], ],
providers: [UserService] providers: [UserService]
}) })

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

@ -31,9 +31,12 @@ import {
DEFAULT_LOCALE, DEFAULT_LOCALE,
PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_MAX_DAILY_REQUESTS,
PROPERTY_REFERRAL_PARTNERS, PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS,
THROTTLE_DAILY_KEY,
THROTTLE_DAILY_TTL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
@ -50,15 +53,18 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler';
import { Prisma, Role, Settings, User } from '@prisma/client'; import { Prisma, Role, Settings, User } from '@prisma/client';
import { differenceInDays, subDays } from 'date-fns'; import { differenceInDays, subDays } from 'date-fns';
import { without } from 'lodash'; import { isNil, without } from 'lodash';
import { createHmac } from 'node:crypto'; import { createHmac } from 'node:crypto';
@Injectable() @Injectable()
export class UserService { export class UserService {
private readonly logger = new Logger(UserService.name);
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@ -67,7 +73,9 @@ export class UserService {
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService private readonly tagService: TagService,
@InjectThrottlerStorage()
private readonly throttlerStorage: ThrottlerStorage
) {} ) {}
public async count(args?: Prisma.UserCountArgs) { public async count(args?: Prisma.UserCountArgs) {
@ -128,6 +136,7 @@ export class UserService {
where: { granteeUserId: id } where: { granteeUserId: id }
}), }),
this.prismaService.account.findMany({ this.prismaService.account.findMany({
include: { platform: true },
orderBy: { orderBy: {
name: 'asc' name: 'asc'
}, },
@ -178,15 +187,15 @@ export class UserService {
systemMessage = systemMessageProperty; systemMessage = systemMessageProperty;
} }
let tags = tagsForUser.filter((tag) => { let tags = tagsForUser;
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === SubscriptionType.Basic subscription.type === SubscriptionType.Basic
) { ) {
tags = []; tags = tags.filter(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
} }
return { return {
@ -228,6 +237,38 @@ export class UserService {
return usersWithAdminRole.length > 0; return usersWithAdminRole.length > 0;
} }
public async isDailyRequestLimitExceeded({
user
}: {
user: UserWithSettings;
}) {
if (user.subscription?.type === SubscriptionType.Premium) {
return false;
}
const maxDailyRequests = await this.getMaxDailyRequests();
if (maxDailyRequests === undefined) {
return false;
}
try {
const { isBlocked } = await this.throttlerStorage.increment(
`${THROTTLE_DAILY_KEY}-${user.id}`,
THROTTLE_DAILY_TTL,
maxDailyRequests,
THROTTLE_DAILY_TTL,
THROTTLE_DAILY_KEY
);
return isBlocked;
} catch (error) {
this.logger.error(error);
return false;
}
}
public async user( public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> { ): Promise<UserWithSettings | null> {
@ -286,7 +327,7 @@ export class UserService {
updatedAt, updatedAt,
activityCount: analytics?.activityCount, activityCount: analytics?.activityCount,
dataProviderGhostfolioDailyRequests: dataProviderGhostfolioDailyRequests:
analytics?.dataProviderGhostfolioDailyRequests analytics?.dataProviderGhostfolioDailyRequests ?? 0
}; };
if (user.settings) { if (user.settings) {
@ -509,9 +550,11 @@ export class UserService {
currentPermissions, currentPermissions,
permissions.accessHoldingsChart, permissions.accessHoldingsChart,
permissions.createAccess, permissions.createAccess,
permissions.createAssetProfileSplitOfOwnAssetProfile,
permissions.createMarketDataOfOwnAssetProfile, permissions.createMarketDataOfOwnAssetProfile,
permissions.createOwnTag, permissions.createOwnTag,
permissions.createWatchlistItem, permissions.createWatchlistItem,
permissions.deleteAssetProfileSplitOfOwnAssetProfile,
permissions.readAiPrompt, permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,
permissions.updateMarketDataOfOwnAssetProfile permissions.updateMarketDataOfOwnAssetProfile
@ -555,7 +598,10 @@ export class UserService {
} }
} else { } else {
if ( if (
await this.propertyService.getByKey<string>(PROPERTY_API_KEY_GHOSTFOLIO) this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') ||
(await this.propertyService.getByKey<string>(
PROPERTY_API_KEY_GHOSTFOLIO
))
) { ) {
currentPermissions.push(permissions.readMarketDataOfMarkets); currentPermissions.push(permissions.readMarketDataOfMarkets);
} }
@ -779,4 +825,26 @@ export class UserService {
return settings; return settings;
} }
private async getMaxDailyRequests() {
const value = await this.propertyService.getByKey<string>(
PROPERTY_MAX_DAILY_REQUESTS
);
if (isNil(value) || value === '') {
return undefined;
}
const maxDailyRequests = Number(value);
if (!Number.isInteger(maxDailyRequests) || maxDailyRequests < 0) {
this.logger.warn(
`The property ${PROPERTY_MAX_DAILY_REQUESTS} is not a non-negative integer ("${value}"), the daily request limit is not applied`
);
return undefined;
}
return maxDailyRequests;
}
} }

16
apps/api/src/dtos/date-range-filter.dto.ts

@ -0,0 +1,16 @@
import { DATE_RANGES, DEFAULT_DATE_RANGE } from '@ghostfolio/common/config';
import { DateRange } from '@ghostfolio/common/types';
import { Matches } from 'class-validator';
import { FilterDto } from './filter.dto';
// A named date range or a calendar year like '2024', '2023', '2022', etc.
export const DATE_RANGE_PATTERN = new RegExp(
`^(${DATE_RANGES.join('|')}|\\d{4})$`
);
export class DateRangeFilterDto extends FilterDto {
@Matches(DATE_RANGE_PATTERN)
range?: DateRange = DEFAULT_DATE_RANGE;
}

23
apps/api/src/dtos/filter.dto.ts

@ -0,0 +1,23 @@
import { IsOptional, IsString } from 'class-validator';
export class FilterDto {
@IsOptional()
@IsString()
accounts?: string;
@IsOptional()
@IsString()
assetClasses?: string;
@IsOptional()
@IsString()
dataSource?: string;
@IsOptional()
@IsString()
symbol?: string;
@IsOptional()
@IsString()
tags?: string;
}

26
apps/api/src/filters/portfolio-snapshot-computation-exception.filter.ts

@ -0,0 +1,26 @@
import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error';
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import { Response } from 'express';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@Catch(PortfolioSnapshotComputationError)
export class PortfolioSnapshotComputationExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(
PortfolioSnapshotComputationExceptionFilter.name
);
public catch(
exception: PortfolioSnapshotComputationError,
host: ArgumentsHost
) {
this.logger.error(exception.message);
const response = host.switchToHttp().getResponse<Response>();
response.status(StatusCodes.SERVICE_UNAVAILABLE).json({
message: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
statusCode: StatusCodes.SERVICE_UNAVAILABLE
});
}
}

18
apps/api/src/helper/account.helper.ts

@ -1,12 +1,28 @@
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = { export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = {
isExcluded: false,
tags: { tags: {
none: { none: {
tagId: TAG_ID_EXCLUDE_FROM_ANALYSIS tagId: TAG_ID_EXCLUDE_FROM_ANALYSIS
} }
} }
}; };
export function getWhereAccountBalanceNotInFuture(): Prisma.AccountBalanceWhereInput {
return {
date: { lte: endOfToday() }
};
}
export function isAccountBalanceInFuture({
date,
endOfTodayDate = endOfToday()
}: {
date: Date;
endOfTodayDate?: Date;
}) {
return isAfter(date, endOfTodayDate);
}

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

@ -38,7 +38,7 @@ export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user accesses: user?.accessesGet
}) || }) ||
isRestrictedView(user) isRestrictedView(user)
) { ) {

2
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -78,6 +78,7 @@ export class TransformDataSourceInResponseInterceptor<
valueMap, valueMap,
object: data, object: data,
paths: [ paths: [
'["filters.dataSource"]',
'activities[*].assetProfile.dataSource', 'activities[*].assetProfile.dataSource',
'activities[*].dataSource', 'activities[*].dataSource',
'assetProfile.dataSource', 'assetProfile.dataSource',
@ -88,6 +89,7 @@ export class TransformDataSourceInResponseInterceptor<
'holdings[*].assetProfile.dataSource', 'holdings[*].assetProfile.dataSource',
'holdings[*].dataSource', 'holdings[*].dataSource',
'items[*].dataSource', 'items[*].dataSource',
'settings["filters.dataSource"]',
'watchlist[*].dataSource' 'watchlist[*].dataSource'
] ]
}); });

12
apps/api/src/services/asset-profile-split/asset-profile-split.module.ts

@ -0,0 +1,12 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { AssetProfileSplitService } from './asset-profile-split.service';
@Module({
exports: [AssetProfileSplitService],
imports: [PrismaModule],
providers: [AssetProfileSplitService]
})
export class AssetProfileSplitModule {}

87
apps/api/src/services/asset-profile-split/asset-profile-split.service.ts

@ -0,0 +1,87 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { AssetProfileSplit } from '@prisma/client';
@Injectable()
export class AssetProfileSplitService {
public constructor(private readonly prismaService: PrismaService) {}
/**
* Deletes the split with the given id of an asset profile and returns
* whether it existed
*/
public async deleteById({
id,
symbolProfileId
}: {
id: string;
symbolProfileId: string;
}) {
const { count } = await this.prismaService.assetProfileSplit.deleteMany({
where: {
id,
symbolProfileId
}
});
return count > 0;
}
/**
* Returns the splits of the given asset profile in ascending order by date
*/
public async getSplits({
dataSource,
symbol
}: AssetProfileIdentifier): Promise<AssetProfileSplit[]> {
return this.prismaService.assetProfileSplit.findMany({
orderBy: [
{
date: 'asc'
}
],
where: {
symbolProfile: {
dataSource,
symbol
}
}
});
}
public async upsert({
date,
denominator,
numerator,
symbolProfileId
}: {
date: Date;
denominator: number;
numerator: number;
symbolProfileId: string;
}): Promise<AssetProfileSplit> {
const dateOfSplit = resetHours(date);
return this.prismaService.assetProfileSplit.upsert({
create: {
denominator,
numerator,
symbolProfileId,
date: dateOfSplit
},
update: {
denominator,
numerator
},
where: {
symbolProfileId_date: {
symbolProfileId,
date: dateOfSplit
}
}
});
}
}

6
apps/api/src/services/benchmark/benchmark.service.ts

@ -159,7 +159,8 @@ export class BenchmarkService {
let benchmarks = let benchmarks =
(await this.propertyService.getByKey<BenchmarkProperty[]>( (await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? []; )) ?? [];
benchmarks.push({ symbolProfileId: assetProfile.id }); benchmarks.push({ symbolProfileId: assetProfile.id });
@ -196,7 +197,8 @@ export class BenchmarkService {
let benchmarks = let benchmarks =
(await this.propertyService.getByKey<BenchmarkProperty[]>( (await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? []; )) ?? [];
benchmarks = benchmarks.filter(({ symbolProfileId }) => { benchmarks = benchmarks.filter(({ symbolProfileId }) => {

14
apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts

@ -50,12 +50,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
response: Partial<SymbolProfile>; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<Partial<SymbolProfile>> { }): Promise<Partial<SymbolProfile>> {
if ( if (!(
!( response.assetClass === 'EQUITY' &&
response.assetClass === 'EQUITY' && ['ETF', 'MUTUALFUND'].includes(response.assetSubClass)
['ETF', 'MUTUALFUND'].includes(response.assetSubClass) )) {
)
) {
return response; return response;
} }
@ -214,8 +212,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return undefined; return undefined;
}) })
.catch(({ message }) => { .catch(({ message }) => {
this.logger.error( this.logger.warn(
`Failed to search Trackinsight symbol for ${symbol} (${message})` `Could not search Trackinsight symbol for "${symbol}": ${message}`
); );
return undefined; return undefined;

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

@ -9,6 +9,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
NON_INVESTMENT_ACTIVITY_TYPES,
PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_DATA_SOURCE_MAPPING PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -20,7 +21,8 @@ import {
getCurrencyFromSymbol, getCurrencyFromSymbol,
getStartOfUtcDate, getStartOfUtcDate,
isCurrency, isCurrency,
isDerivedCurrency isDerivedCurrency,
isValidSearchQuery
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -267,7 +269,7 @@ export class DataProviderService implements OnModuleInit {
if (!assetProfiles[assetProfileIdentifier]) { if (!assetProfiles[assetProfileIdentifier]) {
if ( if (
(dataSource === DataSource.MANUAL && type === 'BUY') || (dataSource === DataSource.MANUAL && type === 'BUY') ||
['FEE', 'INTEREST', 'LIABILITY'].includes(type) NON_INVESTMENT_ACTIVITY_TYPES.includes(type)
) { ) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find( const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(assetProfile) => { (assetProfile) => {
@ -317,7 +319,7 @@ export class DataProviderService implements OnModuleInit {
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")` `${activityPath}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")`
); );
} }
@ -838,7 +840,9 @@ export class DataProviderService implements OnModuleInit {
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
const promises: Promise<LookupResponse>[] = []; const promises: Promise<LookupResponse>[] = [];
if (query?.length < 2) { query = query?.trim();
if (!isValidSearchQuery(query)) {
return { items: lookupItems }; return { items: lookupItems };
} }

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

@ -178,7 +178,7 @@ export class FinancialModelingPrepService
aliases: FinancialModelingPrepService.countriesMapping, aliases: FinancialModelingPrepService.countriesMapping,
name: countryName name: countryName
}), }),
weight: parseFloat(weightPercentage.slice(0, -1)) / 100 weight: parseFloat(`${weightPercentage}`) / 100
}; };
}); });

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

@ -330,7 +330,11 @@ export class YahooFinanceService implements DataProviderInterface {
}); });
} }
} catch (error) { } catch (error) {
this.logger.error(error); if (error?.name === 'BadRequestError') {
this.logger.warn(`Could not search for "${query}": ${error.message}`);
} else {
this.logger.error(error);
}
} }
return { items }; return { items };

70
apps/api/src/services/property/property.service.ts

@ -6,27 +6,39 @@ import {
import { PropertyKey } from '@ghostfolio/common/types'; import { PropertyKey } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client';
import { addMilliseconds, isBefore } from 'date-fns';
import ms from 'ms';
import { PropertyValue } from './interfaces/interfaces'; import { PropertyValue } from './interfaces/interfaces';
@Injectable() @Injectable()
export class PropertyService { export class PropertyService {
private static readonly CACHE_TTL = ms('1 minute');
private cachedProperties: Promise<Property[]>;
private cachedPropertiesExpiresAt: Date;
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async delete({ key }: { key: PropertyKey }) { public async delete({ key }: { key: PropertyKey }) {
return this.prismaService.property.delete({ const property = await this.prismaService.property.delete({
where: { key } where: { key }
}); });
this.invalidateCache();
return property;
} }
public async get() { public async get({ skipCache = false } = {}) {
const response: { const response: {
[key: string]: PropertyValue; [key: string]: PropertyValue;
} = { } = {
[PROPERTY_CURRENCIES]: [] [PROPERTY_CURRENCIES]: []
}; };
const properties = await this.prismaService.property.findMany(); const properties = await this.getProperties({ skipCache });
for (const property of properties) { for (const property of properties) {
let value = property.value; let value = property.value;
@ -41,8 +53,11 @@ export class PropertyService {
return response; return response;
} }
public async getByKey<TValue extends PropertyValue>(aKey: PropertyKey) { public async getByKey<TValue extends PropertyValue>(
const properties = await this.get(); aKey: PropertyKey,
{ skipCache = false } = {}
) {
const properties = await this.get({ skipCache });
return properties[aKey] as TValue; return properties[aKey] as TValue;
} }
@ -53,10 +68,53 @@ export class PropertyService {
} }
public async put({ key, value }: { key: PropertyKey; value: string }) { public async put({ key, value }: { key: PropertyKey; value: string }) {
return this.prismaService.property.upsert({ const property = await this.prismaService.property.upsert({
create: { key, value }, create: { key, value },
update: { value }, update: { value },
where: { key } where: { key }
}); });
this.invalidateCache();
return property;
}
/**
* Returns the properties from the in-memory cache, falling back to the
* database. Callers which write back a modified property must set
* skipCache to avoid basing the write on a stale read.
*/
private async getProperties({ skipCache = false } = {}) {
if (skipCache) {
return this.prismaService.property.findMany();
}
if (
this.cachedProperties &&
isBefore(new Date(), this.cachedPropertiesExpiresAt)
) {
return this.cachedProperties;
}
const properties = this.prismaService.property.findMany().catch((error) => {
if (this.cachedProperties === properties) {
this.invalidateCache();
}
throw error;
});
this.cachedProperties = properties;
this.cachedPropertiesExpiresAt = addMilliseconds(
new Date(),
PropertyService.CACHE_TTL
);
return this.cachedProperties;
}
private invalidateCache() {
this.cachedProperties = undefined;
this.cachedPropertiesExpiresAt = undefined;
} }
} }

2
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

@ -87,7 +87,7 @@ export class PortfolioSnapshotProcessor {
: 0 : 0
); );
this.redisCacheService.set( await this.redisCacheService.set(
this.redisCacheService.getPortfolioSnapshotKey({ this.redisCacheService.getPortfolioSnapshotKey({
filters: job.data.filters, filters: job.data.filters,
userId: job.data.userId userId: job.data.userId

29
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts

@ -1,32 +1,47 @@
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import type { Job, JobId, JobOptions } from 'bull'; import type { Job, JobId, JobOptions } from 'bull';
import ms from 'ms';
import { setTimeout } from 'timers/promises'; import { setTimeout } from 'timers/promises';
import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface';
export const PortfolioSnapshotServiceMock = { export const PortfolioSnapshotServiceMock = {
addJobToQueue({ addJobToQueue: ({
opts opts
}: { }: {
data: PortfolioSnapshotQueueJob; data: PortfolioSnapshotQueueJob;
name: string; name: string;
opts?: JobOptions; opts?: JobOptions;
}): Promise<Job> { }): Promise<Job> => {
const mockJob: Partial<Job> = { const mockJob: Partial<Job> = {
finished: async () => { finished: async () => {
await setTimeout(100); await setTimeout(100);
return Promise.resolve(); // Mimic the processor which caches the computed portfolio snapshot
// under the job id
await RedisCacheServiceMock.set(
opts?.jobId as string,
JSON.stringify({
expiration: Date.now() + ms('1 minute'),
portfolioSnapshot: {}
} as unknown as PortfolioSnapshotValue)
);
} }
}; };
this.jobsStore.set(opts?.jobId, mockJob); PortfolioSnapshotServiceMock.jobsStore.set(opts?.jobId, mockJob);
return Promise.resolve(mockJob as Job); return Promise.resolve(mockJob as Job);
}, },
getJob(jobId: JobId): Promise<Job> { getJob: (jobId: JobId): Promise<Job> => {
const job = this.jobsStore.get(jobId); const job = PortfolioSnapshotServiceMock.jobsStore.get(jobId);
return Promise.resolve(job as Job); return Promise.resolve(job as Job);
}, },
jobsStore: new Map<JobId, Partial<Job>>() jobsStore: new Map<JobId, Partial<Job>>(),
reset: () => {
PortfolioSnapshotServiceMock.jobsStore.clear();
}
}; };

21
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -105,6 +105,27 @@ export class SymbolProfileService {
}; };
} }
public async getCustomSymbolProfilesByNames({
names,
userId
}: {
names: string[];
userId: string;
}): Promise<Pick<SymbolProfile, 'name' | 'symbol'>[]> {
if (names.length === 0) {
return [];
}
return this.prismaService.symbolProfile.findMany({
select: { name: true, symbol: true },
where: {
userId,
dataSource: DataSource.MANUAL,
name: { in: names }
}
});
}
public async getSymbolProfiles( public async getSymbolProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedAssetProfile[]> { ): Promise<EnhancedAssetProfile[]> {

15
apps/client/src/app/app.component.ts

@ -57,12 +57,12 @@ export class GfAppComponent implements OnInit {
public currentRoute: string; public currentRoute: string;
public currentSubRoute: string; public currentSubRoute: string;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean;
public hasInfoMessage: boolean; public hasInfoMessage: boolean;
public hasPermissionToChangeDateRange: boolean; public hasPermissionToChangeDateRange: boolean;
public hasPermissionToChangeFilters: boolean; public hasPermissionToChangeFilters: boolean;
public hasPromotion = false; public hasPromotion = false;
public hasTabs = false; public hasTabs = false;
public impersonationId: string | null;
public info: InfoItem; public info: InfoItem;
public pageTitle: string; public pageTitle: string;
public routerLinkRegister = publicRoutes.register.routerLink; public routerLinkRegister = publicRoutes.register.routerLink;
@ -116,7 +116,7 @@ export class GfAppComponent implements OnInit {
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.impersonationId = impersonationId;
}); });
this.router.events this.router.events
@ -135,7 +135,10 @@ export class GfAppComponent implements OnInit {
this.currentSubRoute === this.currentSubRoute ===
internalRoutes.home.subRoutes?.holdings.path) || internalRoutes.home.subRoutes?.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path && (this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute)) && !this.currentSubRoute) ||
(this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute ===
internalRoutes.portfolio.subRoutes?.activities.path)) &&
this.user?.settings?.viewMode !== 'ZEN' this.user?.settings?.viewMode !== 'ZEN'
) { ) {
this.hasPermissionToChangeDateRange = true; this.hasPermissionToChangeDateRange = true;
@ -288,13 +291,12 @@ export class GfAppComponent implements OnInit {
baseCurrency: this.user?.settings?.baseCurrency, baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme, colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType, deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToAccessAdminControl: hasPermission( hasPermissionToAccessAdminControl: hasPermission(
this.user?.permissions, this.user?.permissions,
permissions.accessAdminControl permissions.accessAdminControl
), ),
hasPermissionToCreateActivity: hasPermissionToCreateActivity:
!this.hasImpersonationId && !this.impersonationId &&
hasPermission( hasPermission(
this.user?.permissions, this.user?.permissions,
permissions.createActivity permissions.createActivity
@ -305,12 +307,13 @@ export class GfAppComponent implements OnInit {
permissions.reportDataGlitch permissions.reportDataGlitch
), ),
hasPermissionToUpdateActivity: hasPermissionToUpdateActivity:
!this.hasImpersonationId && !this.impersonationId &&
hasPermission( hasPermission(
this.user?.permissions, this.user?.permissions,
permissions.updateActivity permissions.updateActivity
) && ) &&
!this.user?.settings?.isRestrictedView, !this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId,
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
height: this.deviceType === 'mobile' ? '98vh' : '80vh', height: this.deviceType === 'mobile' ? '98vh' : '80vh',

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

@ -14,13 +14,19 @@ import {
PortfolioPosition, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import {
hasPermission,
hasReadRestrictedAccessPermission,
permissions
} from '@ghostfolio/common/permissions';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { translate } from '@ghostfolio/ui/i18n';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { import {
@ -42,12 +48,14 @@ import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { NavigationStart, Router } from '@angular/router'; import { NavigationStart, Router } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
albumsOutline, albumsOutline,
cashOutline, cashOutline,
readerOutline,
swapVerticalOutline swapVerticalOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@ -69,6 +77,7 @@ import {
GfDialogHeaderComponent, GfDialogHeaderComponent,
GfHoldingsTableComponent, GfHoldingsTableComponent,
GfInvestmentChartComponent, GfInvestmentChartComponent,
GfTagsSelectorComponent,
GfValueComponent, GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
@ -97,7 +106,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected holdings: PortfolioPosition[]; protected holdings: PortfolioPosition[];
protected interestInBaseCurrency: number; protected interestInBaseCurrency: number;
protected interestInBaseCurrencyPrecision = 2; protected interestInBaseCurrencyPrecision = 2;
protected isLoadingActivities: boolean; protected isLoading = true;
protected isLoadingChart: boolean; protected isLoadingChart: boolean;
protected name: string | null; protected name: string | null;
protected pageIndex = 0; protected pageIndex = 0;
@ -105,6 +114,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected platformName: string; protected platformName: string;
protected sortColumn = 'date'; protected sortColumn = 'date';
protected sortDirection: SortDirection = 'desc'; protected sortDirection: SortDirection = 'desc';
protected tags: Tag[];
protected totalItems: number; protected totalItems: number;
protected user: User; protected user: User;
protected valueInBaseCurrency: number; protected valueInBaseCurrency: number;
@ -148,7 +158,12 @@ export class GfAccountDetailDialogComponent implements OnInit {
} }
}); });
addIcons({ albumsOutline, cashOutline, swapVerticalOutline }); addIcons({
albumsOutline,
cashOutline,
readerOutline,
swapVerticalOutline
});
} }
public ngOnInit() { public ngOnInit() {
@ -214,7 +229,10 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected showValuesInPercentage() { protected showValuesInPercentage() {
return ( return (
this.data.hasImpersonationId || this.user?.settings?.isRestrictedView hasReadRestrictedAccessPermission({
accesses: this.user?.access,
impersonationId: this.data.impersonationId
}) || this.user?.settings?.isRestrictedView
); );
} }
@ -231,6 +249,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
interestInBaseCurrency, interestInBaseCurrency,
name, name,
platform, platform,
tags,
value, value,
valueInBaseCurrency valueInBaseCurrency
}) => { }) => {
@ -280,16 +299,25 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.name = name; this.name = name;
this.platformName = platform?.name ?? '-'; this.platformName = platform?.name ?? '-';
this.tags =
tags?.map((tag) => {
return {
...tag,
name: translate(tag.name)
};
}) ?? [];
this.valueInBaseCurrency = valueInBaseCurrency; this.valueInBaseCurrency = valueInBaseCurrency;
this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
); );
} }
private fetchActivities() { private fetchActivities() {
this.isLoadingActivities = true;
this.dataService this.dataService
.fetchActivities({ .fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }], filters: [{ id: this.data.accountId, type: 'ACCOUNT' }],
@ -303,8 +331,6 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.dataSource = new MatTableDataSource(activities); this.dataSource = new MatTableDataSource(activities);
this.totalItems = count; this.totalItems = count;
this.isLoadingActivities = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
@ -325,8 +351,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
} }
], ],
range: DEFAULT_DATE_RANGE, range: DEFAULT_DATE_RANGE,
withExcludedAccounts: true, withExcludedAccounts: true
withItems: true
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
}).subscribe({ }).subscribe({

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

@ -12,6 +12,7 @@
<gf-value <gf-value
size="large" size="large"
[isCurrency]="true" [isCurrency]="true"
[isLoading]="isLoading"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency" [unit]="user?.settings?.baseCurrency"
[value]="valueInBaseCurrency" [value]="valueInBaseCurrency"
@ -30,70 +31,103 @@
/> />
</div> </div>
<div class="mb-3 row">
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[precision]="balancePrecision"
[unit]="currency"
[value]="balance"
>Cash Balance</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[precision]="equityPrecision"
[unit]="currency"
[value]="equity"
>Equity</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[precision]="interestInBaseCurrencyPrecision"
[unit]="user?.settings?.baseCurrency"
[value]="interestInBaseCurrency"
>Interest</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[precision]="dividendInBaseCurrencyPrecision"
[unit]="user?.settings?.baseCurrency"
[value]="dividendInBaseCurrency"
>Dividend</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="activitiesCount"
>Activities</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="platformName">Platform</gf-value>
</div>
</div>
<mat-tab-group <mat-tab-group
animationDuration="0ms" animationDuration="0ms"
[class.d-none]="isLoadingActivities" class="mb-4"
[disablePagination]="true"
[mat-stretch-tabs]="false" [mat-stretch-tabs]="false"
> >
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="reader-outline" />
<div class="d-none d-sm-block ml-2" i18n>Overview</div>
</ng-template>
<div class="container mt-3 p-0">
<div class="row w-100">
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isCurrency]="true"
[isLoading]="isLoading"
[locale]="user?.settings?.locale"
[precision]="balancePrecision"
[unit]="currency"
[value]="balance"
>Cash Balance</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isCurrency]="true"
[isLoading]="isLoading"
[locale]="user?.settings?.locale"
[precision]="equityPrecision"
[unit]="currency"
[value]="equity"
>Equity</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isCurrency]="true"
[isLoading]="isLoading"
[locale]="user?.settings?.locale"
[precision]="interestInBaseCurrencyPrecision"
[unit]="user?.settings?.baseCurrency"
[value]="interestInBaseCurrency"
>Interest</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isCurrency]="true"
[isLoading]="isLoading"
[locale]="user?.settings?.locale"
[precision]="dividendInBaseCurrencyPrecision"
[unit]="user?.settings?.baseCurrency"
[value]="dividendInBaseCurrency"
>Dividend</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
size="medium"
[isLoading]="isLoading"
[locale]="user?.settings?.locale"
[value]="activitiesCount"
>
@if (activitiesCount === 1) {
<ng-container i18n>Activity</ng-container>
} @else {
<ng-container i18n>Activities</ng-container>
}
</gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isLoading]="isLoading"
[locale]="user?.settings?.locale"
[value]="platformName"
>Platform</gf-value
>
</div>
@if (user?.settings?.isExperimentalFeatures) {
<div class="col-12 mb-3">
<gf-tags-selector [readonly]="true" [tags]="tags" />
</div>
}
</div>
</div>
</mat-tab>
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<ion-icon name="albums-outline" /> <ion-icon name="albums-outline" />
@ -124,8 +158,8 @@
[pageSize]="pageSize" [pageSize]="pageSize"
[showAccountColumn]="false" [showAccountColumn]="false"
[showActions]=" [showActions]="
!data.hasImpersonationId &&
data.hasPermissionToCreateActivity && data.hasPermissionToCreateActivity &&
!data.impersonationId &&
user?.settings?.isExperimentalFeatures && user?.settings?.isExperimentalFeatures &&
!user?.settings?.isRestrictedView !user?.settings?.isRestrictedView
" "
@ -149,8 +183,8 @@
[currentBalance]="balance" [currentBalance]="balance"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]=" [showActions]="
!data.hasImpersonationId &&
hasPermissionToDeleteAccountBalance && hasPermissionToDeleteAccountBalance &&
!data.impersonationId &&
!user.settings.isRestrictedView !user.settings.isRestrictedView
" "
(accountBalanceCreated)="onAddAccountBalance($event)" (accountBalanceCreated)="onAddAccountBalance($event)"

2
apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts

@ -1,8 +1,8 @@
export interface AccountDetailDialogParams { export interface AccountDetailDialogParams {
accountId: string; accountId: string;
deviceType: string; deviceType: string;
hasImpersonationId: boolean;
hasPermissionToCreateActivity: boolean; hasPermissionToCreateActivity: boolean;
impersonationId: string | null;
} }
export interface AccountDetailDialogResult { export interface AccountDetailDialogResult {

62
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -43,6 +43,7 @@ import {
MatPaginatorModule, MatPaginatorModule,
PageEvent PageEvent
} from '@angular/material/paginator'; } from '@angular/material/paginator';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { import {
MatSort, MatSort,
MatSortModule, MatSortModule,
@ -64,6 +65,7 @@ import {
ellipsisVertical, ellipsisVertical,
trashOutline trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -88,6 +90,7 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
MatCheckboxModule, MatCheckboxModule,
MatMenuModule, MatMenuModule,
MatPaginatorModule, MatPaginatorModule,
MatSnackBarModule,
MatSortModule, MatSortModule,
MatTableModule, MatTableModule,
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,
@ -177,6 +180,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { public constructor() {
@ -239,7 +243,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
.subscribe((filters) => { .subscribe((filters) => {
this.activeFilters = filters; this.activeFilters = filters;
this.loadData(); this.reloadData({ pageIndex: 0 });
}); });
addIcons({ addIcons({
@ -285,15 +289,25 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); this.adminMarketDataService
.deleteAssetProfile({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.reloadData();
});
} }
protected onDeleteAssetProfiles() { protected onDeleteAssetProfiles() {
this.adminMarketDataService.deleteAssetProfiles( this.adminMarketDataService
this.selection.selected.map(({ dataSource, symbol }) => { .deleteAssetProfiles(
return { dataSource, symbol }; this.selection.selected.map(({ dataSource, symbol }) => {
}) return { dataSource, symbol };
); })
)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.reloadData();
});
} }
protected onGatherMax() { protected onGatherMax() {
@ -301,9 +315,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
.gatherMax() .gatherMax()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
setTimeout(() => { this.notifyDataGatheringHasBeenStarted();
window.location.reload();
}, 300);
}); });
} }
@ -311,7 +323,9 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
this.adminService this.adminService
.gatherProfileData() .gatherProfileData()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe(() => {
this.notifyDataGatheringHasBeenStarted();
});
} }
protected onGatherRecentMarketData() { protected onGatherRecentMarketData() {
@ -319,9 +333,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
.gatherRecentMarketData() .gatherRecentMarketData()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
setTimeout(() => { this.notifyDataGatheringHasBeenStarted();
window.location.reload();
}, 300);
}); });
} }
@ -396,6 +408,16 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
}); });
} }
private notifyDataGatheringHasBeenStarted() {
this.snackBar.open(
'✅ ' + $localize`Data gathering has been started.`,
undefined,
{
duration: ms('3 seconds')
}
);
}
private openAssetProfileDialog({ private openAssetProfileDialog({
dataSource, dataSource,
symbol symbol
@ -431,6 +453,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
if (newAssetProfileIdentifier) { if (newAssetProfileIdentifier) {
this.onOpenAssetProfileDialog(newAssetProfileIdentifier); this.onOpenAssetProfileDialog(newAssetProfileIdentifier);
} else { } else {
this.reloadData();
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
} }
}); });
@ -483,4 +507,14 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
}); });
}); });
} }
private reloadData({
pageIndex = this.paginator().pageIndex
}: { pageIndex?: number } = {}) {
this.loadData({
pageIndex,
sortColumn: this.sort().active,
sortDirection: this.sort().direction
});
}
} }

17
apps/client/src/app/components/admin-market-data/admin-market-data.service.ts

@ -4,7 +4,7 @@ import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService } from '@ghostfolio/ui/services'; import { AdminService } from '@ghostfolio/ui/services';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { EMPTY, catchError, finalize, forkJoin } from 'rxjs'; import { EMPTY, Subject, catchError, finalize, forkJoin } from 'rxjs';
@Injectable() @Injectable()
export class AdminMarketDataService { export class AdminMarketDataService {
@ -14,25 +14,29 @@ export class AdminMarketDataService {
) {} ) {}
public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
const assetProfileDeleted = new Subject<void>();
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
this.adminService this.adminService
.deleteProfileData({ dataSource, symbol }) .deleteProfileData({ dataSource, symbol })
.subscribe(() => { .subscribe(() => {
setTimeout(() => { assetProfileDeleted.next();
window.location.reload(); assetProfileDeleted.complete();
}, 300);
}); });
}, },
confirmType: ConfirmationDialogType.Warn, confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete this asset profile?` title: $localize`Do you really want to delete this asset profile?`
}); });
return assetProfileDeleted.asObservable();
} }
public deleteAssetProfiles( public deleteAssetProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
) { ) {
const assetProfileCount = aAssetProfileIdentifiers.length; const assetProfileCount = aAssetProfileIdentifiers.length;
const assetProfilesDeleted = new Subject<void>();
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
@ -55,7 +59,8 @@ export class AdminMarketDataService {
return EMPTY; return EMPTY;
}), }),
finalize(() => { finalize(() => {
window.location.reload(); assetProfilesDeleted.next();
assetProfilesDeleted.complete();
}) })
) )
.subscribe(); .subscribe();
@ -66,5 +71,7 @@ export class AdminMarketDataService {
? $localize`Do you really want to delete this asset profile?` ? $localize`Do you really want to delete this asset profile?`
: $localize`Do you really want to delete these ${assetProfileCount}:count: asset profiles?` : $localize`Do you really want to delete these ${assetProfileCount}:count: asset profiles?`
}); });
return assetProfilesDeleted.asObservable();
} }
} }

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

@ -11,7 +11,11 @@ import {
DATE_FORMAT, DATE_FORMAT,
getCountryName, getCountryName,
getCurrencyFromSymbol, getCurrencyFromSymbol,
isCurrency getDateFormatString,
getStringOrNull,
getStringOrUndefined,
isCurrency,
isSplitRatio
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
@ -35,6 +39,7 @@ import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplet
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { TextFieldModule } from '@angular/cdk/text-field'; import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -61,6 +66,7 @@ import {
MatCheckboxChange, MatCheckboxChange,
MatCheckboxModule MatCheckboxModule
} from '@angular/material/checkbox'; } from '@angular/material/checkbox';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
MatDialogModule, MatDialogModule,
@ -74,6 +80,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { import {
AssetClass, AssetClass,
AssetProfileSplit,
AssetSubClass, AssetSubClass,
DataGatheringFrequency, DataGatheringFrequency,
DataSource, DataSource,
@ -86,11 +93,14 @@ import { format } from 'date-fns';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
calendarClearOutline,
codeSlashOutline, codeSlashOutline,
createOutline, createOutline,
ellipsisVertical, ellipsisVertical,
gitCompareOutline,
readerOutline, readerOutline,
serverOutline serverOutline,
trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isBoolean } from 'lodash'; import { isBoolean } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@ -103,6 +113,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column h-100' }, host: { class: 'd-flex flex-column h-100' },
imports: [ imports: [
CommonModule,
FormsModule, FormsModule,
GfCurrencySelectorComponent, GfCurrencySelectorComponent,
GfEntityLogoComponent, GfEntityLogoComponent,
@ -114,6 +125,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatCheckboxModule, MatCheckboxModule,
MatDatepickerModule,
MatDialogModule, MatDialogModule,
MatInputModule, MatInputModule,
MatMenuModule, MatMenuModule,
@ -194,6 +206,26 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
); );
protected readonly assetProfileSplitForm = this.formBuilder.group(
{
date: new FormControl<Date | null>(null, Validators.required),
denominator: new FormControl<number | null>(null, Validators.required),
numerator: new FormControl<number | null>(null, Validators.required)
},
{
validators: (control: AbstractControl): ValidationErrors | null => {
const { denominator, numerator } = control.value as {
denominator: number;
numerator: number;
};
return isSplitRatio({ denominator, numerator })
? null
: { invalidSplitRatio: true };
}
}
);
protected readonly canDeleteAssetProfile = canDeleteAssetProfile; protected readonly canDeleteAssetProfile = canDeleteAssetProfile;
protected canEditAssetProfile = true; protected canEditAssetProfile = true;
@ -245,11 +277,13 @@ export class GfAssetProfileDialogComponent implements OnInit {
value: 'max' value: 'max'
} }
]; ];
protected defaultDateFormat: string;
protected readonly getCountryName = getCountryName; protected readonly getCountryName = getCountryName;
protected historicalDataItems: LineChartItem[]; protected historicalDataItems: LineChartItem[];
protected isBenchmark = false; protected isBenchmark = false;
protected isDataGatheringEnabled: boolean; protected isDataGatheringEnabled: boolean;
protected isEditAssetProfileIdentifierMode = false; protected isEditAssetProfileIdentifierMode = false;
protected isLoading = true;
protected readonly isUUID = isUUID; protected readonly isUUID = isUUID;
protected marketDataItems: MarketData[] = []; protected marketDataItems: MarketData[] = [];
@ -268,6 +302,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
protected splits: AssetProfileSplit[] = [];
protected readonly translate = translate; protected readonly translate = translate;
protected user: User; protected user: User;
@ -291,11 +327,14 @@ export class GfAssetProfileDialogComponent implements OnInit {
private userService: UserService private userService: UserService
) { ) {
addIcons({ addIcons({
calendarClearOutline,
codeSlashOutline, codeSlashOutline,
createOutline, createOutline,
ellipsisVertical, ellipsisVertical,
gitCompareOutline,
readerOutline, readerOutline,
serverOutline serverOutline,
trashOutline
}); });
} }
@ -308,6 +347,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.benchmarks = benchmarks; this.benchmarks = benchmarks;
this.currencies = currencies; this.currencies = currencies;
this.defaultDateFormat = getDateFormatString(this.data.locale);
this.initialize(); this.initialize();
} }
@ -362,8 +402,9 @@ export class GfAssetProfileDialogComponent implements OnInit {
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ assetProfile, marketData }) => { .subscribe(({ assetProfile, marketData, splits }) => {
this.assetProfile = assetProfile; this.assetProfile = assetProfile;
this.splits = splits ?? [];
this.assetClassLabel = translate(this.assetProfile?.assetClass ?? ''); this.assetClassLabel = translate(this.assetProfile?.assetClass ?? '');
this.assetSubClassLabel = translate( this.assetSubClassLabel = translate(
@ -456,6 +497,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileForm.markAsPristine(); this.assetProfileForm.markAsPristine();
this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
@ -491,9 +534,12 @@ export class GfAssetProfileDialogComponent implements OnInit {
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); this.adminMarketDataService
.deleteAssetProfile({ dataSource, symbol })
this.dialogRef.close(); .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.dialogRef.close();
});
} }
protected onGatherProfileDataBySymbol({ protected onGatherProfileDataBySymbol({
@ -519,6 +565,45 @@ export class GfAssetProfileDialogComponent implements OnInit {
.subscribe(); .subscribe();
} }
protected onAddSplit() {
const { date, denominator, numerator } =
this.assetProfileSplitForm.getRawValue();
if (!date || !denominator || !numerator) {
return;
}
this.adminService
.postAssetProfileSplit({
dataSource: this.data.dataSource,
split: {
denominator,
numerator,
date: format(date, DATE_FORMAT)
},
symbol: this.data.symbol
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.assetProfileSplitForm.reset();
this.initialize();
});
}
protected onDeleteSplit(aId: string) {
this.adminService
.deleteAssetProfileSplit({
dataSource: this.data.dataSource,
id: aId,
symbol: this.data.symbol
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.initialize();
});
}
protected onMarketDataChanged(withRefresh: boolean = false) { protected onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) { if (withRefresh) {
this.initialize(); this.initialize();
@ -568,9 +653,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileForm.controls.scraperConfiguration.controls.headers this.assetProfileForm.controls.scraperConfiguration.controls.headers
.value ?? '{}' .value ?? '{}'
) as Record<string, string>, ) as Record<string, string>,
locale: locale: getStringOrUndefined(
this.assetProfileForm.controls.scraperConfiguration.controls.locale this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value ?? undefined, ?.value
),
mode: mode:
this.assetProfileForm.controls.scraperConfiguration.controls.mode this.assetProfileForm.controls.scraperConfiguration.controls.mode
?.value ?? undefined, ?.value ?? undefined,
@ -619,7 +705,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined, assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined,
assetSubClass: assetSubClass:
this.assetProfileForm.controls.assetSubClass.value ?? undefined, this.assetProfileForm.controls.assetSubClass.value ?? undefined,
comment: this.assetProfileForm.controls.comment.value ?? undefined, comment: getStringOrNull(this.assetProfileForm.controls.comment.value),
currency: this.assetProfileForm.controls.currency.value ?? undefined, currency: this.assetProfileForm.controls.currency.value ?? undefined,
dataGatheringFrequency: dataGatheringFrequency:
this.assetProfileForm.controls.dataGatheringFrequency.value ?? this.assetProfileForm.controls.dataGatheringFrequency.value ??
@ -628,7 +714,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
? this.assetProfileForm.controls.isActive.value ? this.assetProfileForm.controls.isActive.value
: undefined, : undefined,
name: this.assetProfileForm.controls.name.value ?? undefined, name: this.assetProfileForm.controls.name.value ?? undefined,
url: this.assetProfileForm.controls.url.value ?? undefined url: getStringOrNull(this.assetProfileForm.controls.url.value)
}; };
try { try {
@ -737,9 +823,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileForm.controls.scraperConfiguration.controls.headers this.assetProfileForm.controls.scraperConfiguration.controls.headers
.value ?? '{}' .value ?? '{}'
) as Record<string, string>, ) as Record<string, string>,
locale: locale: getStringOrUndefined(
this.assetProfileForm.controls.scraperConfiguration.controls.locale this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value ?? undefined, ?.value
),
mode: this.assetProfileForm.controls.scraperConfiguration.controls mode: this.assetProfileForm.controls.scraperConfiguration.controls
.mode?.value, .mode?.value,
selector: selector:

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

@ -105,6 +105,7 @@
<mat-tab-group <mat-tab-group
animationDuration="0ms" animationDuration="0ms"
[disablePagination]="true"
[dynamicHeight]="true" [dynamicHeight]="true"
[mat-stretch-tabs]="false" [mat-stretch-tabs]="false"
> >
@ -182,6 +183,7 @@
i18n i18n
size="medium" size="medium"
[enableCopyToClipboardButton]="true" [enableCopyToClipboardButton]="true"
[isLoading]="isLoading"
[value]="assetProfile?.symbol" [value]="assetProfile?.symbol"
>Symbol</gf-value >Symbol</gf-value
> >
@ -190,6 +192,7 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[isLoading]="isLoading"
[value]=" [value]="
assetProfile?.dataProviderInfo?.name ?? assetProfile?.dataProviderInfo?.name ??
assetProfile?.dataSource assetProfile?.dataSource
@ -212,7 +215,11 @@
</div> </div>
} }
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.currency" <gf-value
i18n
size="medium"
[isLoading]="isLoading"
[value]="assetProfile?.currency"
>Currency</gf-value >Currency</gf-value
> >
</div> </div>
@ -230,6 +237,7 @@
i18n i18n
size="medium" size="medium"
[isDate]="assetProfile?.dateOfFirstActivity ? true : false" [isDate]="assetProfile?.dateOfFirstActivity ? true : false"
[isLoading]="isLoading"
[locale]="data.locale" [locale]="data.locale"
[value]="assetProfile?.dateOfFirstActivity ?? '-'" [value]="assetProfile?.dateOfFirstActivity ?? '-'"
>First Activity</gf-value >First Activity</gf-value
@ -239,6 +247,7 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[isLoading]="isLoading"
[locale]="data.locale" [locale]="data.locale"
[value]="assetProfile?.activitiesCount" [value]="assetProfile?.activitiesCount"
>Activities</gf-value >Activities</gf-value
@ -502,6 +511,124 @@
</div> </div>
</div> </div>
</mat-tab> </mat-tab>
<!-- Splits are not applied to the portfolio calculation yet, hence the
tab to manage them is disabled -->
@if (false && user?.settings?.isExperimentalFeatures) {
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="git-compare-outline" />
<div class="d-none d-sm-block ml-2" i18n>Splits</div>
</ng-template>
<div class="container mt-3 p-0">
<div class="no-gutters row w-100">
<div class="col-12">
@if (splits.length > 0) {
<table class="gf-table mb-3 w-100">
<thead>
<tr class="mat-mdc-header-row">
<th class="mat-mdc-header-cell px-1 py-2" i18n>Date</th>
<th
class="mat-mdc-header-cell px-1 py-2 text-right"
i18n
>
Split Ratio
</th>
<th class="mat-mdc-header-cell px-1 py-2"></th>
</tr>
</thead>
<tbody>
@for (split of splits; track split.id) {
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-1 py-2">
{{ split.date | date: defaultDateFormat }}
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
{{ split.numerator }}:{{ split.denominator }}
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
<button
mat-icon-button
type="button"
[disabled]="!canEditAssetProfile"
(click)="onDeleteSplit(split.id)"
>
<ion-icon name="trash-outline" />
</button>
</td>
</tr>
}
</tbody>
</table>
}
<form
[formGroup]="assetProfileSplitForm"
(ngSubmit)="onAddSplit()"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Date</mat-label>
<input
formControlName="date"
matInput
[matDatepicker]="dateOfSplit"
/>
<mat-datepicker-toggle
class="mr-2"
matSuffix
[for]="dateOfSplit"
>
<ion-icon
class="text-muted"
matDatepickerToggleIcon
name="calendar-clear-outline"
/>
</mat-datepicker-toggle>
<mat-datepicker #dateOfSplit />
</mat-form-field>
<div class="align-items-center d-flex mb-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Shares After</mat-label>
<input
formControlName="numerator"
matInput
step="1"
type="number"
/>
</mat-form-field>
<div class="mx-2">:</div>
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Shares Before</mat-label>
<input
formControlName="denominator"
matInput
step="1"
type="number"
/>
</mat-form-field>
</div>
<div class="text-right">
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="
!assetProfileSplitForm.valid || !canEditAssetProfile
"
>
<ng-container i18n>Add</ng-container>
</button>
</div>
</form>
</div>
</div>
</div>
</mat-tab>
}
@if (assetProfile?.dataSource === 'MANUAL') { @if (assetProfile?.dataSource === 'MANUAL') {
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>

41
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -64,6 +64,7 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms, { StringValue } from 'ms'; import ms, { StringValue } from 'ms';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { catchError, of, switchMap } from 'rxjs';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -105,6 +106,8 @@ export class GfAdminOverviewComponent implements OnInit {
protected readonly info: InfoItem; protected readonly info: InfoItem;
protected isDataGatheringEnabled: boolean; protected isDataGatheringEnabled: boolean;
protected isLoading = false; protected isLoading = false;
protected isReadOnlyMode: boolean;
protected isUserSignupEnabled: boolean;
protected readonly permissions = permissions; protected readonly permissions = permissions;
protected systemMessage: SystemMessage; protected systemMessage: SystemMessage;
protected userCount: number; protected userCount: number;
@ -179,6 +182,8 @@ export class GfAdminOverviewComponent implements OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.isLoading = true;
this.fetchAdminData(); this.fetchAdminData();
} }
@ -270,9 +275,15 @@ export class GfAdminOverviewComponent implements OnInit {
.flush() .flush()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
setTimeout(() => { this.dataService.updateInfo();
window.location.reload();
}, 300); this.snackBar.open(
'✅ ' + $localize`Cache has been flushed.`,
undefined,
{
duration: ms('3 seconds')
}
);
}); });
}, },
confirmType: ConfirmationDialogType.Warn, confirmType: ConfirmationDialogType.Warn,
@ -330,8 +341,6 @@ export class GfAdminOverviewComponent implements OnInit {
} }
private fetchAdminData() { private fetchAdminData() {
this.isLoading = true;
this.adminService this.adminService
.fetchAdminData() .fetchAdminData()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -344,6 +353,11 @@ export class GfAdminOverviewComponent implements OnInit {
this.isDataGatheringEnabled = this.isDataGatheringEnabled =
settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false ? false : true; settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false ? false : true;
this.isReadOnlyMode = settings[PROPERTY_IS_READ_ONLY_MODE] === true;
this.isUserSignupEnabled =
settings[PROPERTY_IS_USER_SIGNUP_ENABLED] === false ? false : true;
this.systemMessage = settings[PROPERTY_SYSTEM_MESSAGE] as SystemMessage; this.systemMessage = settings[PROPERTY_SYSTEM_MESSAGE] as SystemMessage;
this.userCount = userCount; this.userCount = userCount;
this.version = version; this.version = version;
@ -372,11 +386,20 @@ export class GfAdminOverviewComponent implements OnInit {
.putAdminSetting(key, { .putAdminSetting(key, {
value: value || value === false ? JSON.stringify(value) : undefined value: value || value === false ? JSON.stringify(value) : undefined
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(
switchMap(() => {
return this.userService.get(true);
}),
catchError(() => {
// Refresh anyway to reflect the actual state of the settings
return of(undefined);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => { .subscribe(() => {
setTimeout(() => { this.dataService.updateInfo();
window.location.reload();
}, 300); this.fetchAdminData();
}); });
} }

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

@ -7,6 +7,7 @@
i18n i18n
size="large" size="large"
[enableCopyToClipboardButton]="true" [enableCopyToClipboardButton]="true"
[isLoading]="isLoading"
[value]="version" [value]="version"
>Version</gf-value >Version</gf-value
> >
@ -19,6 +20,7 @@
<gf-value <gf-value
i18n i18n
size="large" size="large"
[isLoading]="isLoading"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]="userCount" [value]="userCount"
>Users</gf-value >Users</gf-value
@ -32,6 +34,7 @@
<gf-value <gf-value
i18n i18n
size="large" size="large"
[isLoading]="isLoading"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[subLabel]="activitiesCountPerUser" [subLabel]="activitiesCountPerUser"
[value]="activitiesCount" [value]="activitiesCount"
@ -52,9 +55,8 @@
<mat-slide-toggle <mat-slide-toggle
color="primary" color="primary"
hideIcon="true" hideIcon="true"
[checked]=" [checked]="isUserSignupEnabled"
info.globalPermissions.includes(permissions.createUserAccount) [disabled]="isLoading"
"
(change)="onEnableUserSignupModeChange($event)" (change)="onEnableUserSignupModeChange($event)"
/> />
</div> </div>
@ -66,7 +68,8 @@
<mat-slide-toggle <mat-slide-toggle
color="primary" color="primary"
hideIcon="true" hideIcon="true"
[checked]="info?.isReadOnlyMode" [checked]="isReadOnlyMode"
[disabled]="isLoading"
(change)="onReadOnlyModeChange($event)" (change)="onReadOnlyModeChange($event)"
/> />
</div> </div>
@ -79,6 +82,7 @@
color="primary" color="primary"
hideIcon="true" hideIcon="true"
[checked]="isDataGatheringEnabled" [checked]="isDataGatheringEnabled"
[disabled]="isLoading"
(change)="onEnableDataGatheringChange($event)" (change)="onEnableDataGatheringChange($event)"
/> />
</div> </div>
@ -89,7 +93,9 @@
<div class="w-50"> <div class="w-50">
@if (systemMessage) { @if (systemMessage) {
<div class="align-items-center d-flex"> <div 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
@ -99,7 +105,7 @@
</button> </button>
</div> </div>
} }
@if (!info?.systemMessage) { @if (!systemMessage) {
<button <button
class="mt-2" class="mt-2"
color="accent" color="accent"

13
apps/client/src/app/components/admin-platform/admin-platform.component.html

@ -22,13 +22,12 @@
<ng-container i18n>Name</ng-container> <ng-container i18n>Name</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@if (element.url) { <gf-entity-logo
<gf-entity-logo class="d-inline mr-1"
class="d-inline mr-1" [hasPlaceholder]="true"
[tooltip]="element.name" [tooltip]="element.name"
[url]="element.url" [url]="element.url"
/> />
}
<span>{{ element.name }}</span> <span>{{ element.name }}</span>
</td></ng-container </td></ng-container
> >

25
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -59,7 +59,11 @@
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<gf-entity-logo class="mr-1" [url]="element.url" /> <gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[url]="element.url"
/>
<div> <div>
@if (isGhostfolioDataProvider(element)) { @if (isGhostfolioDataProvider(element)) {
<a <a
@ -105,8 +109,13 @@
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@if ( @if (
!isGhostfolioDataProvider(element) || hasGhostfolioApiKey &&
isGhostfolioApiKeyValid === true isGhostfolioApiKeyValid === false &&
isGhostfolioDataProvider(element)
) {
<span class="text-danger" i18n>Invalid API key</span>
} @else if (
hasGhostfolioApiKey || !isGhostfolioDataProvider(element)
) { ) {
<gf-data-provider-status [dataSource]="element.dataSource" /> <gf-data-provider-status [dataSource]="element.dataSource" />
} }
@ -162,7 +171,7 @@
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
@if (isGhostfolioDataProvider(element)) { @if (isGhostfolioDataProvider(element)) {
@if (isGhostfolioApiKeyValid === true) { @if (hasGhostfolioApiKey) {
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
@ -171,7 +180,11 @@
> >
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #ghostfolioApiMenu="matMenu" xPosition="before"> <mat-menu
#ghostfolioApiMenu="matMenu"
class="no-max-width"
xPosition="before"
>
<button mat-menu-item (click)="onRemoveGhostfolioApiKey()"> <button mat-menu-item (click)="onRemoveGhostfolioApiKey()">
<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" />
@ -179,7 +192,7 @@
</span> </span>
</button> </button>
</mat-menu> </mat-menu>
} @else if (isGhostfolioApiKeyValid === false) { } @else if (hasGhostfolioApiKey === false) {
<button <button
class="special" class="special"
mat-flat-button mat-flat-button

3
apps/client/src/app/components/admin-settings/admin-settings.component.ts

@ -81,6 +81,7 @@ export class GfAdminSettingsComponent implements OnInit {
'actions' 'actions'
]; ];
public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse; public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse;
public hasGhostfolioApiKey: boolean;
public isGhostfolioApiKeyValid: boolean; public isGhostfolioApiKeyValid: boolean;
public isLoading = false; public isLoading = false;
public readonly premiumDataProviderMailHref = `mailto:hi@ghostfol.io?subject=Ghostfolio Premium Data Provider&body=${[ public readonly premiumDataProviderMailHref = `mailto:hi@ghostfol.io?subject=Ghostfolio Premium Data Provider&body=${[
@ -184,6 +185,8 @@ export class GfAdminSettingsComponent implements OnInit {
PROPERTY_API_KEY_GHOSTFOLIO PROPERTY_API_KEY_GHOSTFOLIO
] as string; ] as string;
this.hasGhostfolioApiKey = !!ghostfolioApiKey;
if (ghostfolioApiKey) { if (ghostfolioApiKey) {
this.adminService this.adminService
.fetchGhostfolioDataProviderStatus(ghostfolioApiKey) .fetchGhostfolioDataProviderStatus(ghostfolioApiKey)

12
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -83,7 +83,11 @@
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #tagMenu="matMenu" xPosition="before"> <mat-menu #tagMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateTag(element)"> <button
mat-menu-item
[disabled]="isSystemTag(element)"
(click)="onUpdateTag(element)"
>
<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" />
<span><ng-container i18n>Edit</ng-container>...</span> <span><ng-container i18n>Edit</ng-container>...</span>
@ -92,7 +96,11 @@
<hr class="m-0" /> <hr class="m-0" />
<button <button
mat-menu-item mat-menu-item
[disabled]="element.accountCount > 0 || element.activityCount > 0" [disabled]="
element.accountCount > 0 ||
element.activityCount > 0 ||
isSystemTag(element)
"
(click)="onDeleteTag(element.id)" (click)="onDeleteTag(element.id)"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">

9
apps/client/src/app/components/admin-tag/admin-tag.component.ts

@ -2,7 +2,11 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, getLowercase } from '@ghostfolio/common/helper'; import {
getLocale,
getLowercase,
isSystemTag
} from '@ghostfolio/common/helper';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -67,6 +71,7 @@ export class GfAdminTagComponent implements OnInit {
'activities', 'activities',
'actions' 'actions'
]; ];
protected readonly isSystemTag = isSystemTag;
protected readonly pageSize = DEFAULT_PAGE_SIZE; protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected tags: Tag[]; protected tags: Tag[];
protected readonly translate = translate; protected readonly translate = translate;
@ -99,7 +104,7 @@ export class GfAdminTagComponent implements OnInit {
return id === params['tagId']; return id === params['tagId'];
}); });
if (tag) { if (tag && !isSystemTag(tag)) {
this.openUpdateTagDialog(tag); this.openUpdateTagDialog(tag);
} }
} else { } else {

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

@ -251,7 +251,7 @@
> >
<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" />
<span i18n>Delete User</span> <span i18n>Delete</span>
</span> </span>
</button> </button>
</mat-menu> </mat-menu>

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

@ -1,20 +1,21 @@
import { import {
getTooltipOptions, getChartBorderColor,
getVerticalHoverLinePlugin getChartElementsOptions,
getTimeAxisOptions,
getValueAxisOptions,
getVerticalHoverLinePlugin,
getZeroLineAnnotation
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { import { getLocale, parseDate } from '@ghostfolio/common/helper';
getBackgroundColor,
getDateFormatString,
getLocale,
getTextColor,
parseDate
} from '@ghostfolio/common/helper';
import { LineChartItem, User } from '@ghostfolio/common/interfaces'; import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { registerChartConfiguration } from '@ghostfolio/ui/chart'; import {
getTimeSeriesTooltipOptions,
registerChartConfiguration
} from '@ghostfolio/ui/chart';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { import {
@ -166,28 +167,13 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
data, data,
options: { options: {
animation: false, animation: false,
elements: { elements: getChartElementsOptions(this.colorScheme()),
line: {
tension: 0
},
point: {
hoverBackgroundColor: getBackgroundColor(this.colorScheme()),
hoverRadius: 2,
radius: 0
}
},
interaction: { intersect: false, mode: 'index' }, interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true, maintainAspectRatio: true,
plugins: { plugins: {
annotation: { annotation: {
annotations: { annotations: {
yAxis: { yAxis: getZeroLineAnnotation(this.colorScheme())
borderColor: `rgba(${getTextColor(this.colorScheme())}, 0.1)`,
borderWidth: 1,
scaleID: 'y',
type: 'line',
value: 0
}
} }
}, },
legend: { legend: {
@ -195,54 +181,21 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
}, },
tooltip: this.getTooltipPluginConfiguration(), tooltip: this.getTooltipPluginConfiguration(),
verticalHoverLine: { verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme())}, 0.1)` color: getChartBorderColor(this.colorScheme())
} }
}, },
responsive: true, responsive: true,
scales: { scales: {
x: { x: getTimeAxisOptions({
border: { colorScheme: this.colorScheme(),
color: `rgba(${getTextColor(this.colorScheme())}, 0.1)`, locale: this.locale()
width: 1 }),
}, y: getValueAxisOptions({
display: true, colorScheme: this.colorScheme(),
grid: { tickCallback: (tickValue) => {
display: false return `${Number(tickValue).toFixed(2)} %`;
},
type: 'time',
time: {
tooltipFormat: getDateFormatString(this.locale()),
unit: 'year'
} }
}, })
y: {
border: {
width: 0
},
display: true,
grid: {
color: ({ scale, tick }) => {
if (
tick.value === 0 ||
tick.value === scale.max ||
tick.value === scale.min
) {
return `rgba(${getTextColor(this.colorScheme())}, 0.1)`;
}
return 'transparent';
}
},
position: 'right',
ticks: {
callback: (value: number) => {
return `${value.toFixed(2)} %`;
},
display: true,
mirror: true,
z: 1
}
}
} }
}, },
plugins: [ plugins: [
@ -255,16 +208,10 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
} }
private getTooltipPluginConfiguration(): Partial<TooltipOptions<'line'>> { private getTooltipPluginConfiguration(): Partial<TooltipOptions<'line'>> {
return { return getTimeSeriesTooltipOptions<'line'>({
...getTooltipOptions({ colorScheme: this.colorScheme(),
colorScheme: this.colorScheme(), locale: this.locale(),
locale: this.locale(), unit: '%'
unit: '%' });
}),
mode: 'index',
position: 'top',
xAlign: 'center',
yAlign: 'bottom'
};
} }
} }

24
apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html

@ -1,24 +0,0 @@
<div class="position-relative">
<div class="align-items-center d-flex flex-row" [hidden]="!fearAndGreedIndex">
<div class="h2 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div>
<div>
<div class="h4 mb-0">
<span class="mr-2">{{ fearAndGreedIndexText }}</span>
<small class="text-muted"
><strong>{{ fearAndGreedIndex | number: '1.0-0' }}</strong
>/100</small
>
</div>
<small class="d-block" i18n>Current Market Mood</small>
</div>
</div>
@if (!fearAndGreedIndex) {
<ngx-skeleton-loader
animation="pulse"
class="position-absolute w-100"
[theme]="{
height: '100%'
}"
/>
}
</div>

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

Loading…
Cancel
Save