Browse Source

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

pull/7427/head
Thomas Kaul 3 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 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
### 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 `ai` from version `6.0.174` to `7.0.37`
### Fixed
- 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

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 { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { AccountBalance, Prisma } from '@prisma/client';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
import { endOfToday, format, parseISO } from 'date-fns';
import { groupBy } from 'lodash';
@Injectable()
@ -114,8 +117,13 @@ export class AccountBalanceService {
const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } =
{};
const lastBalancesByAccount: { [accountId: string]: Big } = {};
const endOfTodayDate = endOfToday();
for (const { accountId, date, valueInBaseCurrency } of balances) {
if (isAccountBalanceInFuture({ date, endOfTodayDate })) {
continue;
}
const formattedDate = format(date, DATE_FORMAT);
lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency);

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

@ -156,32 +156,34 @@ export class AccountController {
public async createAccount(
@Body() data: CreateAccountDto
): Promise<AccountModel> {
const { tags: tagIds, ...accountData } = data;
const { balance, tags: tagIds, ...accountData } = data;
if (accountData.platformId) {
const platformId = accountData.platformId;
delete accountData.platformId;
return this.accountService.createAccount(
{
return this.accountService.createAccount({
balance,
tagIds,
data: {
...accountData,
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
this.request.user.id,
tagIds
);
userId: this.request.user.id
});
} else {
delete accountData.platformId;
return this.accountService.createAccount(
{
return this.accountService.createAccount({
balance,
tagIds,
data: {
...accountData,
user: { connect: { id: this.request.user.id } }
},
this.request.user.id,
tagIds
);
userId: this.request.user.id
});
}
}
@ -257,52 +259,50 @@ export class AccountController {
);
}
const { tags: tagIds, ...accountData } = data;
const { balance, tags: tagIds, ...accountData } = data;
if (accountData.platformId) {
const platformId = accountData.platformId;
delete accountData.platformId;
return this.accountService.updateAccount(
{
data: {
...accountData,
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
return this.accountService.updateAccount({
balance,
tagIds,
data: {
...accountData,
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
this.request.user.id,
tagIds
);
userId: this.request.user.id,
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
});
} else {
// platformId is null, remove it
delete accountData.platformId;
return this.accountService.updateAccount(
{
data: {
...accountData,
platform: originalAccount.platformId
? { disconnect: true }
: undefined,
user: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
return this.accountService.updateAccount({
balance,
tagIds,
data: {
...accountData,
platform: originalAccount.platformId
? { disconnect: true }
: undefined,
user: { connect: { id: this.request.user.id } }
},
this.request.user.id,
tagIds
);
userId: this.request.user.id,
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 { 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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces';
import { AccountWithBalance } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
@ -19,8 +24,8 @@ import {
Tag
} from '@prisma/client';
import { Big } from 'big.js';
import { format } from 'date-fns';
import { groupBy } from 'lodash';
import { endOfToday, format } from 'date-fns';
import { groupBy, isNil } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface';
@ -36,12 +41,14 @@ export class AccountService {
public async account({
id_userId
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
}: Prisma.AccountWhereUniqueInput): Promise<AccountWithBalance | null> {
const account = await this.prismaService.account.findUnique({
include: {
balances: {
orderBy: { date: 'desc' },
take: 1
take: 1,
// Ignore account balances in the future
where: getWhereAccountBalanceNotInFuture()
}
},
where: { id_userId }
@ -81,7 +88,7 @@ export class AccountService {
where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise<
(Account & {
(AccountWithBalance & {
activities?: (Order & { SymbolProfile?: SymbolProfile })[];
balances?: AccountBalance[];
platform?: Platform;
@ -95,7 +102,16 @@ export class AccountService {
include.balances = {
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) {
@ -115,10 +131,17 @@ export class AccountService {
where
});
const endOfTodayDate = endOfToday();
return accounts.map((account) => {
const result = {
...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
? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => {
return tag;
@ -138,12 +161,18 @@ export class AccountService {
});
}
public async createAccount(
data: Prisma.AccountCreateInput,
aUserId: string,
tagIds?: string[]
): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId: aUserId });
public async createAccount({
balance,
data,
tagIds,
userId
}: {
balance?: number;
data: Prisma.AccountCreateInput;
tagIds?: string[];
userId: string;
}): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId });
const account = await this.prismaService.account.create({
data: {
@ -160,12 +189,14 @@ export class AccountService {
}
});
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: account.id,
balance: data.balance,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
if (!isNil(balance)) {
await this.accountBalanceService.createOrUpdateAccountBalance({
balance,
userId,
accountId: account.id,
date: format(new Date(), DATE_FORMAT)
});
}
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
@ -194,7 +225,7 @@ export class AccountService {
return account;
}
public async getAccounts(aUserId: string): Promise<Account[]> {
public async getAccounts(aUserId: string): Promise<AccountWithBalance[]> {
const accounts = await this.accounts({
include: {
activities: true,
@ -273,17 +304,20 @@ export class AccountService {
};
}
public async updateAccount(
params: {
data: Prisma.AccountUpdateInput;
where: Prisma.AccountWhereUniqueInput;
},
aUserId: string,
tagIds?: string[]
): Promise<Account> {
const { data, where } = params;
await this.tagService.validateTagIds({ tagIds, userId: aUserId });
public async updateAccount({
balance,
data,
tagIds,
userId,
where
}: {
balance?: number;
data: Prisma.AccountUpdateInput;
tagIds?: string[];
userId: string;
where: Prisma.AccountWhereUniqueInput;
}): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId });
const account = await this.prismaService.account.update({
data: {
@ -302,12 +336,14 @@ export class AccountService {
where
});
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: account.id,
balance: data.balance as number,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
if (!isNil(balance)) {
await this.accountBalanceService.createOrUpdateAccountBalance({
balance,
userId,
accountId: account.id,
date: format(new Date(), DATE_FORMAT)
});
}
this.eventEmitter.emit(
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 {
accounts: Account[];
accounts: AccountWithBalance[];
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
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -29,7 +29,6 @@ import {
HttpException,
Inject,
Param,
ParseIntPipe,
Post,
Put,
Query,
@ -38,11 +37,13 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ActivitiesFilterDto } from './activities-filter.dto';
import { ActivitiesService } from './activities.service';
import { GetActivitiesDto } from './get-activities.dto';
@Controller('activities')
export class ActivitiesController {
@ -60,22 +61,47 @@ export class ActivitiesController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteActivities(
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query()
{
accounts,
activityTypes,
assetClasses,
dataSource,
range,
symbol,
tags
}: ActivitiesFilterDto
): 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({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByDataSource: dataSource,
filterBySymbol: symbol,
filterByTags: tags
});
return this.activitiesService.deleteActivities({
endDate,
filters,
startDate,
types: activityTypes,
userId: this.request.user.id
});
}
@ -108,38 +134,41 @@ export class ActivitiesController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange,
@Query('skip', new ParseIntPipe({ optional: true })) skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('take', new ParseIntPipe({ optional: true })) take?: number
@Query()
{
accounts,
activityTypes,
assetClasses,
dataSource,
range,
skip,
sortColumn,
sortDirection,
symbol,
tags,
take
}: GetActivitiesDto
): Promise<ActivitiesResponse> {
let endDate: Date;
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getIntervalFromDateRange({ dateRange }));
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByDataSource: dataSource,
filterBySymbol: symbol,
filterByTags: tags
});
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.activitiesService.getActivities({
@ -150,9 +179,9 @@ export class ActivitiesController {
sortDirection,
startDate,
take,
types,
userCurrency,
includeDrafts: true,
types: activityTypes,
userId: impersonationUserId || this.request.user.id,
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 { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -17,12 +20,13 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
ghostfolioPrefix,
NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import {
canDeleteAssetProfile,
getAssetProfileIdentifier
getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper';
import {
ActivitiesResponse,
@ -45,7 +49,6 @@ import {
Type as ActivityType
} from '@prisma/client';
import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto';
@ -189,7 +192,7 @@ export class ActivitiesService {
const userId = data.userId;
if (
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) ||
NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' &&
data.type === 'BUY')
) {
@ -201,10 +204,9 @@ export class ActivitiesService {
let symbol: string;
if (
data.SymbolProfile.connectOrCreate.create.symbol.startsWith(
`${ghostfolioPrefix}_`
) ||
isUUID(data.SymbolProfile.connectOrCreate.create.symbol)
isValidCustomAssetProfileSymbol(
data.SymbolProfile.connectOrCreate.create.symbol
)
) {
// Connect custom asset profile (clone)
symbol = data.SymbolProfile.connectOrCreate.create.symbol;
@ -259,7 +261,7 @@ export class ActivitiesService {
const orderData: Prisma.OrderCreateInput = data;
const isDraft = ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type)
const isDraft = NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type)
? false
: isAfter(data.date as Date, endOfToday());
@ -358,14 +360,23 @@ export class ActivitiesService {
}
public async deleteActivities({
endDate,
filters,
startDate,
types,
userId
}: {
endDate?: Date;
filters?: Filter[];
startDate?: Date;
types?: ActivityType[];
userId: string;
}): Promise<number> {
const { activities } = await this.getActivities({
endDate,
filters,
startDate,
types,
userId,
includeDrafts: true,
userCurrency: undefined,
@ -456,6 +467,7 @@ export class ActivitiesService {
}
const activities: Activity[] = [];
const endOfTodayDate = endOfToday();
for (const account of cashDetails.accounts) {
const { balances } = await this.accountBalanceService.getAccountBalances({
@ -468,6 +480,15 @@ export class ActivitiesService {
let currentBalanceInBaseCurrency = 0;
for (const balanceItem of balances) {
if (
isAccountBalanceInFuture({
endOfTodayDate,
date: balanceItem.date
})
) {
continue;
}
const syntheticActivityTemplate: Activity = {
userId,
accountId: account.id,
@ -961,7 +982,7 @@ export class ActivitiesService {
let isDraft = false;
if (
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) ||
NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' &&
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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
ghostfolioPrefix,
PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
@ -14,7 +15,8 @@ import {
import {
applyAssetProfileOverrides,
getAssetProfileIdentifier,
getCurrencyFromSymbol
getCurrencyFromSymbol,
hasGhostfolioPrefix
} from '@ghostfolio/common/helper';
import {
AdminData,
@ -63,6 +65,12 @@ export class AdminService {
> {
try {
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({
currency,
dataSource,
@ -107,8 +115,11 @@ export class AdminService {
await this.marketDataService.deleteMany({ dataSource, 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)) {
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 { PortfolioSnapshotComputationExceptionFilter } from '@ghostfolio/api/filters/portfolio-snapshot-computation-exception.filter';
import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper';
import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.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 { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
@ -184,7 +186,13 @@ import { UserModule } from './user/user.module';
UserModule,
WatchlistModule
],
providers: [I18nService]
providers: [
I18nService,
{
provide: APP_FILTER,
useClass: PortfolioSnapshotComputationExceptionFilter
}
]
})
export class AppModule implements NestModule {
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('symbol') symbol: string
): Promise<AssetResponse> {
const { assetProfile, marketData } =
const { assetProfile, marketData, splits } =
await this.assetProfilesService.getAssetProfile({
dataSource,
symbol
@ -28,6 +28,7 @@ export class AssetController {
return {
marketData,
splits,
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({
create: { user: { connect: { id: user.id } } },
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 =
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 { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
@ -31,18 +32,15 @@ export class AiController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPrompt(
@Param('mode') mode: AiPromptMode,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
@Query()
{ accounts, assetClasses, dataSource, symbol, tags }: FilterDto
): Promise<AiPromptResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByDataSource: dataSource,
filterBySymbol: symbol,
filterByTags: tags
});
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 { ApiService } from '@ghostfolio/api/services/api/api.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 { AssetProfileResponse } from '@ghostfolio/common/interfaces';
import {
@ -18,19 +21,22 @@ import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
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 { 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)
@Patch(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -139,4 +190,47 @@ export class AssetProfilesController {
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 { 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 { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.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 { Module } from '@nestjs/common';
@ -20,7 +22,9 @@ import { AssetProfilesService } from './asset-profiles.service';
imports: [
ActivitiesModule,
ApiModule,
AssetProfileSplitModule,
BenchmarkModule,
DataGatheringQueueModule,
DataProviderModule,
ExchangeRateDataModule,
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 { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.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 { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
import {
@ -30,7 +32,9 @@ import { groupBy } from 'lodash';
export class AssetProfilesService {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly assetProfileSplitService: AssetProfileSplitService,
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
@ -38,6 +42,48 @@ export class AssetProfilesService {
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({
dataSource,
symbol
@ -54,7 +100,7 @@ export class AssetProfilesService {
await this.activitiesService.getStatisticsByCurrency(currency));
}
const [[assetProfile], marketData] = await Promise.all([
const [[assetProfile], marketData, splits] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
{
dataSource,
@ -69,7 +115,8 @@ export class AssetProfilesService {
dataSource,
symbol
}
})
}),
this.assetProfileSplitService.getSplits({ dataSource, symbol })
]);
if (assetProfile) {
@ -80,6 +127,7 @@ export class AssetProfilesService {
return {
marketData,
splits,
assetProfile: assetProfile ?? {
activitiesCount,
currency,
@ -92,6 +140,7 @@ export class AssetProfilesService {
}
};
}
public async getAssetProfiles({
filters = [],
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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DEFAULT_DATE_RANGE,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type {
AssetProfileIdentifier,
BenchmarkMarketDataDetailsResponse,
BenchmarkResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -37,6 +34,7 @@ import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarksService } from './benchmarks.service';
import { GetBenchmarkMarketDataDto } from './get-benchmark-market-data.dto';
@Controller('benchmarks')
export class BenchmarksController {
@ -121,38 +119,39 @@ export class BenchmarksController {
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
@Query()
{
accounts,
assetClasses,
dataSource: filterByDataSource,
range,
symbol: filterBySymbol,
tags,
withExcludedAccounts
}: GetBenchmarkMarketDataDto
): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange({
dateRange,
dateRange: range,
startDate: new Date(startDateString)
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByTags: tags
});
const withExcludedAccounts = withExcludedAccountsParam === 'true';
return this.benchmarksService.getMarketDataForUser({
dataSource,
dateRange,
endDate,
filters,
impersonationId,
startDate,
symbol,
withExcludedAccounts,
dateRange: range,
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();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -90,12 +90,12 @@ export class GhostfolioController {
@Version('2')
public async getDividends(
@Param('symbol') symbol: string,
@Query() query: GetDividendsDto
@Query() { from, granularity, to }: GetDividendsDto
): Promise<DividendsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -105,10 +105,10 @@ export class GhostfolioController {
try {
const dividends = await this.ghostfolioService.getDividends({
granularity,
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
from: parseDate(from),
to: parseDate(to)
});
await this.ghostfolioService.incrementDailyRequests({
@ -130,12 +130,12 @@ export class GhostfolioController {
@Version('2')
public async getHistorical(
@Param('symbol') symbol: string,
@Query() query: GetHistoricalDto
@Query() { from, granularity, to }: GetHistoricalDto
): Promise<HistoricalResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -145,10 +145,10 @@ export class GhostfolioController {
try {
const historicalData = await this.ghostfolioService.getHistorical({
granularity,
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
from: parseDate(from),
to: parseDate(to)
});
await this.ghostfolioService.incrementDailyRequests({
@ -176,7 +176,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -215,7 +215,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -247,12 +247,12 @@ export class GhostfolioController {
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getQuotes(
@Query() query: GetQuotesDto
@Query() { symbols }: GetQuotesDto
): Promise<QuotesResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -262,7 +262,7 @@ export class GhostfolioController {
try {
const quotes = await this.ghostfolioService.getQuotes({
symbols: query.symbols
symbols
});
await this.ghostfolioService.incrementDailyRequests({

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

@ -17,7 +17,10 @@ import {
DERIVED_CURRENCIES
} 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 {
DataProviderGhostfolioAssetProfileResponse,
DataProviderHistoricalResponse,
@ -326,8 +329,12 @@ export class GhostfolioService {
}
public async incrementDailyRequests({ userId }: { userId: string }) {
await this.prismaService.analytics.update({
data: {
await this.prismaService.analytics.upsert({
create: {
dataProviderGhostfolioDailyRequests: 1,
user: { connect: { id: userId } }
},
update: {
dataProviderGhostfolioDailyRequests: { increment: 1 }
},
where: { userId }
@ -340,7 +347,9 @@ export class GhostfolioService {
}: GetSearchParams): Promise<LookupResponse> {
const results: LookupResponse = { items: [] };
if (!query) {
query = query?.trim();
if (!isValidSearchQuery(query)) {
return results;
}
@ -348,10 +357,6 @@ export class GhostfolioService {
let lookupItems: LookupItem[] = [];
const promises: Promise<{ items: LookupItem[] }>[] = [];
if (query?.length < 2) {
return { items: lookupItems };
}
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
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 { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { isSystemTag } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
@ -69,7 +70,7 @@ export class TagsController {
id
});
if (!originalTag) {
if (!originalTag || isSystemTag(originalTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -94,7 +95,7 @@ export class TagsController {
id
});
if (!originalTag) {
if (!originalTag || isSystemTag(originalTag)) {
throw new HttpException(
getReasonPhrase(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 { 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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { ExportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -15,9 +16,9 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service';
import { GetExportDto } from './get-export.dto';
@Controller('export')
export class ExportController {
@ -32,29 +33,41 @@ export class ExportController {
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async export(
@Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
@Query()
{
accounts,
activityIds,
activityTypes,
assetClasses,
dataSource,
range,
symbol,
tags
}: GetExportDto
): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? [];
let endDate: Date;
let startDate: Date;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByDataSource: dataSource,
filterBySymbol: symbol,
filterByTags: tags
});
return this.exportService.export({
activityIds,
activityTypes,
endDate,
filters,
startDate,
userId: this.request.user.id,
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({
activityIds,
activityTypes,
endDate,
filters,
startDate,
userId,
userSettings
}: {
activityIds?: string[];
activityTypes?: ActivityType[];
endDate?: Date;
filters?: Filter[];
startDate?: Date;
userId: string;
userSettings: UserSettings;
}): Promise<ExportResponse> {
@ -41,7 +45,9 @@ export class ExportService {
const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.activitiesService.getActivities({
endDate,
filters,
startDate,
userId,
includeDrafts: true,
sortColumn: 'date',
@ -67,6 +73,13 @@ export class ExportService {
};
}
const isFilteredExport =
activityIds?.length > 0 ||
activityTypes?.length > 0 ||
filters?.length > 0 ||
!!endDate ||
!!startDate;
const accounts = (
await this.accountService.accounts({
where,
@ -81,7 +94,7 @@ export class ExportService {
})
)
.filter(({ id }) => {
return activityIds?.length > 0
return isFilteredExport
? activities.some(({ accountId }) => {
return accountId === id;
})
@ -89,30 +102,26 @@ export class ExportService {
})
.map(
({
balance,
balances,
comment,
currency,
id,
isExcluded,
name,
platform,
platformId,
tags
}) => {
}): ExportResponse['accounts'][number] => {
if (platformId) {
platformsMap[platformId] = platform;
}
return {
balance,
balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value };
}),
comment,
currency,
id,
isExcluded,
name,
platformId,
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() {
try {
await this.propertyService.getByKey(PROPERTY_CURRENCIES);
await this.propertyService.getByKey(PROPERTY_CURRENCIES, {
skipCache: true
});
return true;
} catch {

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

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

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

@ -77,6 +77,7 @@ export class ImportController {
accountsWithBalancesDto: importData.accounts ?? [],
activitiesDto: importData.activities,
assetProfilesWithMarketDataDto: importData.assetProfiles ?? [],
platformsDto: importData.platforms ?? [],
tagsDto: importData.tags ?? [],
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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.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 {
getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol,
parseDate
} from '@ghostfolio/common/helper';
import {
@ -174,6 +180,7 @@ export class ImportService {
assetProfilesWithMarketDataDto,
isDryRun = false,
maxActivitiesToImport,
platformsDto,
tagsDto,
user
}: {
@ -182,14 +189,89 @@ export class ImportService {
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
isDryRun?: boolean;
maxActivitiesToImport: number;
platformsDto: ImportDataDto['platforms'];
tagsDto: ImportDataDto['tags'];
user: UserWithSettings;
}): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {};
const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {};
const platformIdMapping: { [oldPlatformId: string]: string } = {};
const tagIdMapping: { [oldTagId: string]: string } = {};
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 =
tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length)
? 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 (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account = omit(accountWithBalances, ['balances', 'tags']);
const account = omit(accountWithBalances, [
'balance',
'balances',
'isExcluded',
'tags'
]);
let oldAccountId: string;
const platformId = account.platformId;
const platformId =
platformIdMapping[account.platformId] ?? account.platformId;
delete account.platformId;
@ -292,6 +380,16 @@ export class ImportService {
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 = {
...account,
balances: {
@ -311,11 +409,12 @@ export class ImportService {
};
}
const newAccount = await this.accountService.createAccount(
accountObject,
user.id,
tagIds
);
const newAccount = await this.accountService.createAccount({
tagIds,
balance: accountWithBalances.balance,
data: accountObject,
userId: user.id
});
// Store the new to old account ID mappings for updating activities
if (accountWithSameId && oldAccountId) {
@ -325,71 +424,117 @@ export class ImportService {
}
}
if (!isDryRun && assetProfilesWithMarketDataDto?.length) {
const existingAssetProfiles =
await this.symbolProfileService.getSymbolProfiles(
assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
if (assetProfilesWithMarketDataDto?.length) {
const customAssetProfileNames = assetProfilesWithMarketDataDto
.filter(({ dataSource, name }) => {
return dataSource === DataSource.MANUAL && Boolean(name);
})
.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) {
let symbol = assetProfileWithMarketData.symbol;
// Check if there is any existing asset profile
const existingAssetProfile = existingAssetProfiles.find(
({ dataSource, symbol }) => {
(assetProfile) => {
return (
dataSource === assetProfileWithMarketData.dataSource &&
symbol === assetProfileWithMarketData.symbol
assetProfile.dataSource ===
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) {
const assetProfile: CreateAssetProfileDto = omit(
assetProfileWithMarketData,
'marketData'
);
// Check if the user has a custom asset profile with the same name.
// Skip asset profiles with a legacy free-text symbol as they would
// 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;
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
const assetProfileObject: Prisma.SymbolProfileCreateInput = {
...assetProfile,
user: { connect: { id: user.id } }
};
if (symbol !== assetProfileWithMarketData.symbol) {
assetProfileSymbolMapping[assetProfileWithMarketData.symbol] =
symbol;
await this.symbolProfileService.add(assetProfileObject);
// Keep the asset profile in sync with the activities to validate
assetProfileWithMarketData.symbol = symbol;
}
}
// Insert or update market data
const marketDataObjects = assetProfileWithMarketData.marketData.map(
(marketData) => {
if (!isDryRun) {
// Insert or update market data
const marketDataObjects = (
assetProfileWithMarketData.marketData ?? []
).map((marketData) => {
return {
...marketData,
dataSource: assetProfileWithMarketData.dataSource,
symbol: assetProfileWithMarketData.symbol
symbol,
dataSource: assetProfileWithMarketData.dataSource
} as Prisma.MarketDataUpdateInput;
}
);
});
await this.marketDataService.updateMany({ data: marketDataObjects });
await this.marketDataService.updateMany({ data: marketDataObjects });
}
}
}
for (const activity of activitiesDto) {
if (!activity.dataSource) {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) {
activity.dataSource = DataSource.MANUAL;
} else {
activity.dataSource =
this.dataProviderService.getDataSourceForImport();
}
// If an asset profile is created or reused, then update the symbol in all activities
if (assetProfileSymbolMapping[activity.symbol]) {
activity.symbol = assetProfileSymbolMapping[activity.symbol];
}
if (!isDryRun) {
@ -398,11 +543,6 @@ export class ImportService {
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
activity.tags = (activity.tags ?? []).map((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 { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error';
import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-position.interface';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.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 {
protected static readonly ENABLE_LOGGING = false;
private static readonly MAX_INITIALIZATION_ATTEMPTS = 3;
protected readonly logger = new Logger(PortfolioCalculator.name);
protected accountBalanceItems: HistoricalDataItem[];
@ -174,6 +177,11 @@ export abstract class PortfolioCalculator {
this.computeTransactionPoints();
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(
@ -1124,7 +1132,7 @@ export abstract class PortfolioCalculator {
}
@LogPerformance
private async initialize() {
private async initialize(attempt = 1) {
const startTimeTotal = performance.now();
let cachedPortfolioSnapshot: PortfolioSnapshot;
@ -1183,6 +1191,12 @@ export abstract class PortfolioCalculator {
});
}
} 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
await this.portfolioSnapshotService.addJobToQueue({
data: {
@ -1205,7 +1219,7 @@ export abstract class PortfolioCalculator {
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;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
exchangeRateDataService = new ExchangeRateDataService(
@ -166,6 +169,14 @@ describe('PortfolioCalculator', () => {
id: randomUUID(),
value: 2000,
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'),
currency: 'USD',
id: accountId,
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2023-12-31'),
@ -354,7 +364,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'),
currency: 'CHF',
id: accountId,
isExcluded: false,
name: 'CHF',
platformId: null,
updatedAt: parseDate('2023-12-31'),
@ -486,7 +495,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'),
currency: 'CHF',
id: accountId,
isExcluded: false,
name: 'CHF',
platformId: null,
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;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
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;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DEFAULT_DATE_RANGE,
HEADER_KEY_IMPERSONATION,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
@ -34,11 +33,7 @@ import {
isRestrictedView,
permissions
} from '@ghostfolio/common/permissions';
import type {
DateRange,
GroupBy,
RequestWithUser
} from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -60,6 +55,11 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js';
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 { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@ -82,16 +82,17 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withMarkets') withMarketsParam = 'false'
@Query()
{
accounts: filterByAccounts,
assetClasses: filterByAssetClasses,
dataSource: filterByDataSource,
range,
symbol: filterBySymbol,
tags: filterByTags,
withMarkets
}: GetDetailsDto
): Promise<PortfolioDetails & { hasError: boolean }> {
const withMarkets = withMarketsParam === 'true';
let hasDetails = true;
let hasError = false;
@ -118,10 +119,10 @@ export class PortfolioController {
platforms,
summary
} = await this.portfolioService.getDetails({
dateRange,
filters,
impersonationId,
withMarkets,
dateRange: range,
userId: this.request.user.id,
withSummary: true
});
@ -135,7 +136,7 @@ export class PortfolioController {
if (
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user)
) {
@ -179,7 +180,7 @@ export class PortfolioController {
hasDetails === false ||
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user)
) {
@ -326,20 +327,23 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getDividends(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
@Query()
{
accounts,
assetClasses,
dataSource,
groupBy,
range,
symbol,
tags
}: GetDividendsDto
): Promise<PortfolioDividendsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByDataSource: dataSource,
filterBySymbol: symbol,
filterByTags: tags
});
const impersonationUserId =
@ -349,7 +353,9 @@ export class PortfolioController {
const { settings } = await this.userService.user({ id: userId });
const userCurrency = settings.settings.baseCurrency;
const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { endDate, startDate } = getIntervalFromDateRange({
dateRange: range
});
const { activities } = await this.activitiesService.getActivities({
endDate,
@ -368,7 +374,7 @@ export class PortfolioController {
if (
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user)
) {
@ -429,29 +435,32 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getHoldings(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('holdingType') filterByHoldingType?: string,
@Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
@Query()
{
accounts,
assetClasses,
dataSource,
holdingType,
query,
range,
symbol,
tags
}: GetHoldingsDto
): Promise<PortfolioHoldingsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterByHoldingType,
filterBySearchQuery,
filterBySymbol,
filterByTags
filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByDataSource: dataSource,
filterByHoldingType: holdingType,
filterBySearchQuery: query,
filterBySymbol: symbol,
filterByTags: tags
});
const holdings = await this.portfolioService.getHoldings({
dateRange,
filters,
impersonationId,
dateRange: range,
userId: this.request.user.id
});
@ -463,35 +472,38 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getInvestments(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
@Query()
{
accounts,
assetClasses,
dataSource,
groupBy,
range,
symbol,
tags
}: GetInvestmentsDto
): Promise<PortfolioInvestmentsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByDataSource: dataSource,
filterBySymbol: symbol,
filterByTags: tags
});
let { investments, streaks } = await this.portfolioService.getInvestments({
dateRange,
filters,
groupBy,
impersonationId,
savingsRate: this.request.user?.settings?.settings.savingsRate,
userId: this.request.user.id
});
let { investments, savingsRate, streaks } =
await this.portfolioService.getInvestments({
filters,
groupBy,
impersonationId,
dateRange: range,
userId: this.request.user.id
});
if (
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user)
) {
@ -509,6 +521,8 @@ export class PortfolioController {
'currentStreak',
'longestStreak'
]);
savingsRate = null;
}
if (
@ -525,7 +539,7 @@ export class PortfolioController {
]);
}
return { investments, streaks };
return { investments, savingsRate, streaks };
}
@Get('performance')
@ -536,36 +550,37 @@ export class PortfolioController {
@Version('2')
public async getPerformanceV2(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
@Query()
{
accounts,
assetClasses,
dataSource,
range,
symbol,
tags,
withExcludedAccounts
}: GetPerformanceDto
): Promise<PortfolioPerformanceResponse> {
const withExcludedAccounts = withExcludedAccountsParam === 'true';
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByDataSource: dataSource,
filterBySymbol: symbol,
filterByTags: tags
});
const performanceInformation = await this.portfolioService.getPerformance({
dateRange,
filters,
impersonationId,
withExcludedAccounts,
dateRange: range,
userId: this.request.user.id
});
if (
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user) ||
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 { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
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 { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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 { UNKNOWN_KEY } from '@ghostfolio/common/config';
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 { randomUUID } from 'node:crypto';
@ -92,6 +97,7 @@ describe('PortfolioService', () => {
null,
null,
null,
null,
null
);
@ -187,7 +193,6 @@ describe('PortfolioService', () => {
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: randomUUID(),
isExcluded: false,
name: 'USD',
platformId: null,
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 () => {
const accountId = randomUUID();
const cashAccount: Account = {
const cashAccount: AccountWithBalance = {
balance: 2000,
comment: null,
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: accountId,
isExcluded: false,
name: 'USD',
platformId: null,
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', () => {
const getValueOfAccountsAndPlatforms = (args: object) => {
return (
@ -353,7 +437,6 @@ describe('PortfolioService', () => {
balance: 100,
currency: 'USD',
id: randomUUID(),
isExcluded: false,
name: 'Account 1',
platform: { name: 'Platform 1' },
platformId: randomUUID()
@ -362,7 +445,7 @@ describe('PortfolioService', () => {
beforeEach(() => {
jest
.spyOn(accountService, 'getAccounts')
.mockResolvedValue([account] as unknown as Account[]);
.mockResolvedValue([account] as unknown as AccountWithBalance[]);
jest
.spyOn(exchangeRateDataService, 'toCurrency')

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

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

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

@ -54,7 +54,9 @@ export class SubscriptionController {
}
let coupons =
(await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS)) ?? [];
(await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS, {
skipCache: true
})) ?? [];
const coupon = coupons.find((currentCoupon) => {
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 { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.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 { 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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -119,6 +121,7 @@ export class UserController {
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getUser(
@Headers('accept-language') acceptLanguage: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
@ -165,6 +168,7 @@ export class UserController {
@Put('setting')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if (
size(data) === 1 &&
@ -192,6 +196,12 @@ export class UserController {
data
);
if (userSettings['filters.dataSource']) {
userSettings['filters.dataSource'] = decodeDataSource(
userSettings['filters.dataSource']
);
}
for (const key in userSettings) {
if (userSettings[key] === false || userSettings[key] === null) {
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 { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
@ -30,7 +31,8 @@ import { UserService } from './user.service';
PropertyModule,
RedactValuesInResponseModule,
SubscriptionModule,
TagModule
TagModule,
TransformDataSourceInResponseModule
],
providers: [UserService]
})

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

@ -31,9 +31,12 @@ import {
DEFAULT_LOCALE,
PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_MAX_DAILY_REQUESTS,
PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE,
TAG_ID_EXCLUDE_FROM_ANALYSIS
TAG_ID_EXCLUDE_FROM_ANALYSIS,
THROTTLE_DAILY_KEY,
THROTTLE_DAILY_TTL
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
@ -50,15 +53,18 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types';
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 { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler';
import { Prisma, Role, Settings, User } from '@prisma/client';
import { differenceInDays, subDays } from 'date-fns';
import { without } from 'lodash';
import { isNil, without } from 'lodash';
import { createHmac } from 'node:crypto';
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService,
@ -67,7 +73,9 @@ export class UserService {
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService
private readonly tagService: TagService,
@InjectThrottlerStorage()
private readonly throttlerStorage: ThrottlerStorage
) {}
public async count(args?: Prisma.UserCountArgs) {
@ -128,6 +136,7 @@ export class UserService {
where: { granteeUserId: id }
}),
this.prismaService.account.findMany({
include: { platform: true },
orderBy: {
name: 'asc'
},
@ -178,15 +187,15 @@ export class UserService {
systemMessage = systemMessageProperty;
}
let tags = tagsForUser.filter((tag) => {
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
let tags = tagsForUser;
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === SubscriptionType.Basic
) {
tags = [];
tags = tags.filter(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
}
return {
@ -228,6 +237,38 @@ export class UserService {
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(
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> {
@ -286,7 +327,7 @@ export class UserService {
updatedAt,
activityCount: analytics?.activityCount,
dataProviderGhostfolioDailyRequests:
analytics?.dataProviderGhostfolioDailyRequests
analytics?.dataProviderGhostfolioDailyRequests ?? 0
};
if (user.settings) {
@ -509,9 +550,11 @@ export class UserService {
currentPermissions,
permissions.accessHoldingsChart,
permissions.createAccess,
permissions.createAssetProfileSplitOfOwnAssetProfile,
permissions.createMarketDataOfOwnAssetProfile,
permissions.createOwnTag,
permissions.createWatchlistItem,
permissions.deleteAssetProfileSplitOfOwnAssetProfile,
permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile,
permissions.updateMarketDataOfOwnAssetProfile
@ -555,7 +598,10 @@ export class UserService {
}
} else {
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);
}
@ -779,4 +825,26 @@ export class UserService {
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 { Prisma } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = {
isExcluded: false,
tags: {
none: {
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 (
hasReadRestrictedAccessPermission({
impersonationId,
user
accesses: user?.accessesGet
}) ||
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,
object: data,
paths: [
'["filters.dataSource"]',
'activities[*].assetProfile.dataSource',
'activities[*].dataSource',
'assetProfile.dataSource',
@ -88,6 +89,7 @@ export class TransformDataSourceInResponseInterceptor<
'holdings[*].assetProfile.dataSource',
'holdings[*].dataSource',
'items[*].dataSource',
'settings["filters.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 =
(await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS
PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? [];
benchmarks.push({ symbolProfileId: assetProfile.id });
@ -196,7 +197,8 @@ export class BenchmarkService {
let benchmarks =
(await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS
PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? [];
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>;
symbol: string;
}): Promise<Partial<SymbolProfile>> {
if (
!(
response.assetClass === 'EQUITY' &&
['ETF', 'MUTUALFUND'].includes(response.assetSubClass)
)
) {
if (!(
response.assetClass === 'EQUITY' &&
['ETF', 'MUTUALFUND'].includes(response.assetSubClass)
)) {
return response;
}
@ -214,8 +212,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return undefined;
})
.catch(({ message }) => {
this.logger.error(
`Failed to search Trackinsight symbol for ${symbol} (${message})`
this.logger.warn(
`Could not search Trackinsight symbol for "${symbol}": ${message}`
);
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 {
DEFAULT_CURRENCY,
DERIVED_CURRENCIES,
NON_INVESTMENT_ACTIVITY_TYPES,
PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config';
@ -20,7 +21,8 @@ import {
getCurrencyFromSymbol,
getStartOfUtcDate,
isCurrency,
isDerivedCurrency
isDerivedCurrency,
isValidSearchQuery
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
@ -267,7 +269,7 @@ export class DataProviderService implements OnModuleInit {
if (!assetProfiles[assetProfileIdentifier]) {
if (
(dataSource === DataSource.MANUAL && type === 'BUY') ||
['FEE', 'INTEREST', 'LIABILITY'].includes(type)
NON_INVESTMENT_ACTIVITY_TYPES.includes(type)
) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(assetProfile) => {
@ -317,7 +319,7 @@ export class DataProviderService implements OnModuleInit {
if (!assetProfile?.name) {
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[] = [];
const promises: Promise<LookupResponse>[] = [];
if (query?.length < 2) {
query = query?.trim();
if (!isValidSearchQuery(query)) {
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,
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) {
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 };

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

@ -6,27 +6,39 @@ import {
import { PropertyKey } from '@ghostfolio/common/types';
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';
@Injectable()
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 async delete({ key }: { key: PropertyKey }) {
return this.prismaService.property.delete({
const property = await this.prismaService.property.delete({
where: { key }
});
this.invalidateCache();
return property;
}
public async get() {
public async get({ skipCache = false } = {}) {
const response: {
[key: string]: PropertyValue;
} = {
[PROPERTY_CURRENCIES]: []
};
const properties = await this.prismaService.property.findMany();
const properties = await this.getProperties({ skipCache });
for (const property of properties) {
let value = property.value;
@ -41,8 +53,11 @@ export class PropertyService {
return response;
}
public async getByKey<TValue extends PropertyValue>(aKey: PropertyKey) {
const properties = await this.get();
public async getByKey<TValue extends PropertyValue>(
aKey: PropertyKey,
{ skipCache = false } = {}
) {
const properties = await this.get({ skipCache });
return properties[aKey] as TValue;
}
@ -53,10 +68,53 @@ export class PropertyService {
}
public async put({ key, value }: { key: PropertyKey; value: string }) {
return this.prismaService.property.upsert({
const property = await this.prismaService.property.upsert({
create: { key, value },
update: { value },
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
);
this.redisCacheService.set(
await this.redisCacheService.set(
this.redisCacheService.getPortfolioSnapshotKey({
filters: job.data.filters,
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 ms from 'ms';
import { setTimeout } from 'timers/promises';
import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface';
export const PortfolioSnapshotServiceMock = {
addJobToQueue({
addJobToQueue: ({
opts
}: {
data: PortfolioSnapshotQueueJob;
name: string;
opts?: JobOptions;
}): Promise<Job> {
}): Promise<Job> => {
const mockJob: Partial<Job> = {
finished: async () => {
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);
},
getJob(jobId: JobId): Promise<Job> {
const job = this.jobsStore.get(jobId);
getJob: (jobId: JobId): Promise<Job> => {
const job = PortfolioSnapshotServiceMock.jobsStore.get(jobId);
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(
aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedAssetProfile[]> {

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

@ -57,12 +57,12 @@ export class GfAppComponent implements OnInit {
public currentRoute: string;
public currentSubRoute: string;
public deviceType: string;
public hasImpersonationId: boolean;
public hasInfoMessage: boolean;
public hasPermissionToChangeDateRange: boolean;
public hasPermissionToChangeFilters: boolean;
public hasPromotion = false;
public hasTabs = false;
public impersonationId: string | null;
public info: InfoItem;
public pageTitle: string;
public routerLinkRegister = publicRoutes.register.routerLink;
@ -116,7 +116,7 @@ export class GfAppComponent implements OnInit {
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.impersonationId = impersonationId;
});
this.router.events
@ -135,7 +135,10 @@ export class GfAppComponent implements OnInit {
this.currentSubRoute ===
internalRoutes.home.subRoutes?.holdings.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.hasPermissionToChangeDateRange = true;
@ -288,13 +291,12 @@ export class GfAppComponent implements OnInit {
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToAccessAdminControl: hasPermission(
this.user?.permissions,
permissions.accessAdminControl
),
hasPermissionToCreateActivity:
!this.hasImpersonationId &&
!this.impersonationId &&
hasPermission(
this.user?.permissions,
permissions.createActivity
@ -305,12 +307,13 @@ export class GfAppComponent implements OnInit {
permissions.reportDataGlitch
),
hasPermissionToUpdateActivity:
!this.hasImpersonationId &&
!this.impersonationId &&
hasPermission(
this.user?.permissions,
permissions.updateActivity
) &&
!this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId,
locale: this.user?.settings?.locale
},
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,
User
} 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 { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { translate } from '@ghostfolio/ui/i18n';
import { DataService } from '@ghostfolio/ui/services';
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
import { GfValueComponent } from '@ghostfolio/ui/value';
import {
@ -42,12 +48,14 @@ import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { NavigationStart, Router } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons';
import {
albumsOutline,
cashOutline,
readerOutline,
swapVerticalOutline
} from 'ionicons/icons';
import { isNumber } from 'lodash';
@ -69,6 +77,7 @@ import {
GfDialogHeaderComponent,
GfHoldingsTableComponent,
GfInvestmentChartComponent,
GfTagsSelectorComponent,
GfValueComponent,
IonIcon,
MatButtonModule,
@ -97,7 +106,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected holdings: PortfolioPosition[];
protected interestInBaseCurrency: number;
protected interestInBaseCurrencyPrecision = 2;
protected isLoadingActivities: boolean;
protected isLoading = true;
protected isLoadingChart: boolean;
protected name: string | null;
protected pageIndex = 0;
@ -105,6 +114,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected platformName: string;
protected sortColumn = 'date';
protected sortDirection: SortDirection = 'desc';
protected tags: Tag[];
protected totalItems: number;
protected user: User;
protected valueInBaseCurrency: number;
@ -148,7 +158,12 @@ export class GfAccountDetailDialogComponent implements OnInit {
}
});
addIcons({ albumsOutline, cashOutline, swapVerticalOutline });
addIcons({
albumsOutline,
cashOutline,
readerOutline,
swapVerticalOutline
});
}
public ngOnInit() {
@ -214,7 +229,10 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected showValuesInPercentage() {
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,
name,
platform,
tags,
value,
valueInBaseCurrency
}) => {
@ -280,16 +299,25 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.name = name;
this.platformName = platform?.name ?? '-';
this.tags =
tags?.map((tag) => {
return {
...tag,
name: translate(tag.name)
};
}) ?? [];
this.valueInBaseCurrency = valueInBaseCurrency;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
}
);
}
private fetchActivities() {
this.isLoadingActivities = true;
this.dataService
.fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }],
@ -303,8 +331,6 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.dataSource = new MatTableDataSource(activities);
this.totalItems = count;
this.isLoadingActivities = false;
this.changeDetectorRef.markForCheck();
});
}
@ -325,8 +351,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
}
],
range: DEFAULT_DATE_RANGE,
withExcludedAccounts: true,
withItems: true
withExcludedAccounts: true
})
.pipe(takeUntilDestroyed(this.destroyRef))
}).subscribe({

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

@ -12,6 +12,7 @@
<gf-value
size="large"
[isCurrency]="true"
[isLoading]="isLoading"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="valueInBaseCurrency"
@ -30,70 +31,103 @@
/>
</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
animationDuration="0ms"
[class.d-none]="isLoadingActivities"
class="mb-4"
[disablePagination]="true"
[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>
<ng-template mat-tab-label>
<ion-icon name="albums-outline" />
@ -124,8 +158,8 @@
[pageSize]="pageSize"
[showAccountColumn]="false"
[showActions]="
!data.hasImpersonationId &&
data.hasPermissionToCreateActivity &&
!data.impersonationId &&
user?.settings?.isExperimentalFeatures &&
!user?.settings?.isRestrictedView
"
@ -149,8 +183,8 @@
[currentBalance]="balance"
[locale]="user?.settings?.locale"
[showActions]="
!data.hasImpersonationId &&
hasPermissionToDeleteAccountBalance &&
!data.impersonationId &&
!user.settings.isRestrictedView
"
(accountBalanceCreated)="onAddAccountBalance($event)"

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

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

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

@ -43,6 +43,7 @@ import {
MatPaginatorModule,
PageEvent
} from '@angular/material/paginator';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import {
MatSort,
MatSortModule,
@ -64,6 +65,7 @@ import {
ellipsisVertical,
trashOutline
} from 'ionicons/icons';
import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
@ -88,6 +90,7 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
MatCheckboxModule,
MatMenuModule,
MatPaginatorModule,
MatSnackBarModule,
MatSortModule,
MatTableModule,
NgxSkeletonLoaderModule,
@ -177,6 +180,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly userService = inject(UserService);
public constructor() {
@ -239,7 +243,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
.subscribe((filters) => {
this.activeFilters = filters;
this.loadData();
this.reloadData({ pageIndex: 0 });
});
addIcons({
@ -285,15 +289,25 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
this.adminMarketDataService
.deleteAssetProfile({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.reloadData();
});
}
protected onDeleteAssetProfiles() {
this.adminMarketDataService.deleteAssetProfiles(
this.selection.selected.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
);
this.adminMarketDataService
.deleteAssetProfiles(
this.selection.selected.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.reloadData();
});
}
protected onGatherMax() {
@ -301,9 +315,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
.gatherMax()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
this.notifyDataGatheringHasBeenStarted();
});
}
@ -311,7 +323,9 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
this.adminService
.gatherProfileData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
.subscribe(() => {
this.notifyDataGatheringHasBeenStarted();
});
}
protected onGatherRecentMarketData() {
@ -319,9 +333,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
.gatherRecentMarketData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
this.notifyDataGatheringHasBeenStarted();
});
}
@ -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({
dataSource,
symbol
@ -431,6 +453,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
if (newAssetProfileIdentifier) {
this.onOpenAssetProfileDialog(newAssetProfileIdentifier);
} else {
this.reloadData();
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 { Injectable } from '@angular/core';
import { EMPTY, catchError, finalize, forkJoin } from 'rxjs';
import { EMPTY, Subject, catchError, finalize, forkJoin } from 'rxjs';
@Injectable()
export class AdminMarketDataService {
@ -14,25 +14,29 @@ export class AdminMarketDataService {
) {}
public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
const assetProfileDeleted = new Subject<void>();
this.notificationService.confirm({
confirmFn: () => {
this.adminService
.deleteProfileData({ dataSource, symbol })
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
assetProfileDeleted.next();
assetProfileDeleted.complete();
});
},
confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete this asset profile?`
});
return assetProfileDeleted.asObservable();
}
public deleteAssetProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[]
) {
const assetProfileCount = aAssetProfileIdentifiers.length;
const assetProfilesDeleted = new Subject<void>();
this.notificationService.confirm({
confirmFn: () => {
@ -55,7 +59,8 @@ export class AdminMarketDataService {
return EMPTY;
}),
finalize(() => {
window.location.reload();
assetProfilesDeleted.next();
assetProfilesDeleted.complete();
})
)
.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 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,
getCountryName,
getCurrencyFromSymbol,
isCurrency
getDateFormatString,
getStringOrNull,
getStringOrUndefined,
isCurrency,
isSplitRatio
} from '@ghostfolio/common/helper';
import {
AdminMarketDataDetails,
@ -35,6 +39,7 @@ import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplet
import { GfValueComponent } from '@ghostfolio/ui/value';
import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
@ -61,6 +66,7 @@ import {
MatCheckboxChange,
MatCheckboxModule
} from '@angular/material/checkbox';
import { MatDatepickerModule } from '@angular/material/datepicker';
import {
MAT_DIALOG_DATA,
MatDialogModule,
@ -74,6 +80,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { IonIcon } from '@ionic/angular/standalone';
import {
AssetClass,
AssetProfileSplit,
AssetSubClass,
DataGatheringFrequency,
DataSource,
@ -86,11 +93,14 @@ import { format } from 'date-fns';
import { StatusCodes } from 'http-status-codes';
import { addIcons } from 'ionicons';
import {
calendarClearOutline,
codeSlashOutline,
createOutline,
ellipsisVertical,
gitCompareOutline,
readerOutline,
serverOutline
serverOutline,
trashOutline
} from 'ionicons/icons';
import { isBoolean } from 'lodash';
import ms from 'ms';
@ -103,6 +113,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column h-100' },
imports: [
CommonModule,
FormsModule,
GfCurrencySelectorComponent,
GfEntityLogoComponent,
@ -114,6 +125,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
IonIcon,
MatButtonModule,
MatCheckboxModule,
MatDatepickerModule,
MatDialogModule,
MatInputModule,
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 canEditAssetProfile = true;
@ -245,11 +277,13 @@ export class GfAssetProfileDialogComponent implements OnInit {
value: 'max'
}
];
protected defaultDateFormat: string;
protected readonly getCountryName = getCountryName;
protected historicalDataItems: LineChartItem[];
protected isBenchmark = false;
protected isDataGatheringEnabled: boolean;
protected isEditAssetProfileIdentifierMode = false;
protected isLoading = true;
protected readonly isUUID = isUUID;
protected marketDataItems: MarketData[] = [];
@ -268,6 +302,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
[name: string]: { name: string; value: number };
};
protected splits: AssetProfileSplit[] = [];
protected readonly translate = translate;
protected user: User;
@ -291,11 +327,14 @@ export class GfAssetProfileDialogComponent implements OnInit {
private userService: UserService
) {
addIcons({
calendarClearOutline,
codeSlashOutline,
createOutline,
ellipsisVertical,
gitCompareOutline,
readerOutline,
serverOutline
serverOutline,
trashOutline
});
}
@ -308,6 +347,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.benchmarks = benchmarks;
this.currencies = currencies;
this.defaultDateFormat = getDateFormatString(this.data.locale);
this.initialize();
}
@ -362,8 +402,9 @@ export class GfAssetProfileDialogComponent implements OnInit {
symbol: this.data.symbol
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ assetProfile, marketData }) => {
.subscribe(({ assetProfile, marketData, splits }) => {
this.assetProfile = assetProfile;
this.splits = splits ?? [];
this.assetClassLabel = translate(this.assetProfile?.assetClass ?? '');
this.assetSubClassLabel = translate(
@ -456,6 +497,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileForm.markAsPristine();
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
@ -491,9 +534,12 @@ export class GfAssetProfileDialogComponent implements OnInit {
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
this.dialogRef.close();
this.adminMarketDataService
.deleteAssetProfile({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.dialogRef.close();
});
}
protected onGatherProfileDataBySymbol({
@ -519,6 +565,45 @@ export class GfAssetProfileDialogComponent implements OnInit {
.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) {
if (withRefresh) {
this.initialize();
@ -568,9 +653,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileForm.controls.scraperConfiguration.controls.headers
.value ?? '{}'
) as Record<string, string>,
locale:
locale: getStringOrUndefined(
this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value ?? undefined,
?.value
),
mode:
this.assetProfileForm.controls.scraperConfiguration.controls.mode
?.value ?? undefined,
@ -619,7 +705,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined,
assetSubClass:
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,
dataGatheringFrequency:
this.assetProfileForm.controls.dataGatheringFrequency.value ??
@ -628,7 +714,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
? this.assetProfileForm.controls.isActive.value
: undefined,
name: this.assetProfileForm.controls.name.value ?? undefined,
url: this.assetProfileForm.controls.url.value ?? undefined
url: getStringOrNull(this.assetProfileForm.controls.url.value)
};
try {
@ -737,9 +823,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileForm.controls.scraperConfiguration.controls.headers
.value ?? '{}'
) as Record<string, string>,
locale:
locale: getStringOrUndefined(
this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value ?? undefined,
?.value
),
mode: this.assetProfileForm.controls.scraperConfiguration.controls
.mode?.value,
selector:

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

@ -105,6 +105,7 @@
<mat-tab-group
animationDuration="0ms"
[disablePagination]="true"
[dynamicHeight]="true"
[mat-stretch-tabs]="false"
>
@ -182,6 +183,7 @@
i18n
size="medium"
[enableCopyToClipboardButton]="true"
[isLoading]="isLoading"
[value]="assetProfile?.symbol"
>Symbol</gf-value
>
@ -190,6 +192,7 @@
<gf-value
i18n
size="medium"
[isLoading]="isLoading"
[value]="
assetProfile?.dataProviderInfo?.name ??
assetProfile?.dataSource
@ -212,7 +215,11 @@
</div>
}
<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
>
</div>
@ -230,6 +237,7 @@
i18n
size="medium"
[isDate]="assetProfile?.dateOfFirstActivity ? true : false"
[isLoading]="isLoading"
[locale]="data.locale"
[value]="assetProfile?.dateOfFirstActivity ?? '-'"
>First Activity</gf-value
@ -239,6 +247,7 @@
<gf-value
i18n
size="medium"
[isLoading]="isLoading"
[locale]="data.locale"
[value]="assetProfile?.activitiesCount"
>Activities</gf-value
@ -502,6 +511,124 @@
</div>
</div>
</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') {
<mat-tab>
<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';
import ms, { StringValue } from 'ms';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { catchError, of, switchMap } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -105,6 +106,8 @@ export class GfAdminOverviewComponent implements OnInit {
protected readonly info: InfoItem;
protected isDataGatheringEnabled: boolean;
protected isLoading = false;
protected isReadOnlyMode: boolean;
protected isUserSignupEnabled: boolean;
protected readonly permissions = permissions;
protected systemMessage: SystemMessage;
protected userCount: number;
@ -179,6 +182,8 @@ export class GfAdminOverviewComponent implements OnInit {
}
public ngOnInit() {
this.isLoading = true;
this.fetchAdminData();
}
@ -270,9 +275,15 @@ export class GfAdminOverviewComponent implements OnInit {
.flush()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
this.dataService.updateInfo();
this.snackBar.open(
'✅ ' + $localize`Cache has been flushed.`,
undefined,
{
duration: ms('3 seconds')
}
);
});
},
confirmType: ConfirmationDialogType.Warn,
@ -330,8 +341,6 @@ export class GfAdminOverviewComponent implements OnInit {
}
private fetchAdminData() {
this.isLoading = true;
this.adminService
.fetchAdminData()
.pipe(takeUntilDestroyed(this.destroyRef))
@ -344,6 +353,11 @@ export class GfAdminOverviewComponent implements OnInit {
this.isDataGatheringEnabled =
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.userCount = userCount;
this.version = version;
@ -372,11 +386,20 @@ export class GfAdminOverviewComponent implements OnInit {
.putAdminSetting(key, {
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(() => {
setTimeout(() => {
window.location.reload();
}, 300);
this.dataService.updateInfo();
this.fetchAdminData();
});
}

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

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

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

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

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

@ -59,7 +59,11 @@
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<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>
@if (isGhostfolioDataProvider(element)) {
<a
@ -105,8 +109,13 @@
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
@if (
!isGhostfolioDataProvider(element) ||
isGhostfolioApiKeyValid === true
hasGhostfolioApiKey &&
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" />
}
@ -162,7 +171,7 @@
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
@if (isGhostfolioDataProvider(element)) {
@if (isGhostfolioApiKeyValid === true) {
@if (hasGhostfolioApiKey) {
<button
class="mx-1 no-min-width px-2"
mat-button
@ -171,7 +180,11 @@
>
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #ghostfolioApiMenu="matMenu" xPosition="before">
<mat-menu
#ghostfolioApiMenu="matMenu"
class="no-max-width"
xPosition="before"
>
<button mat-menu-item (click)="onRemoveGhostfolioApiKey()">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
@ -179,7 +192,7 @@
</span>
</button>
</mat-menu>
} @else if (isGhostfolioApiKeyValid === false) {
} @else if (hasGhostfolioApiKey === false) {
<button
class="special"
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'
];
public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse;
public hasGhostfolioApiKey: boolean;
public isGhostfolioApiKeyValid: boolean;
public isLoading = false;
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
] as string;
this.hasGhostfolioApiKey = !!ghostfolioApiKey;
if (ghostfolioApiKey) {
this.adminService
.fetchGhostfolioDataProviderStatus(ghostfolioApiKey)

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

@ -83,7 +83,11 @@
<ion-icon name="ellipsis-horizontal" />
</button>
<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">
<ion-icon class="mr-2" name="create-outline" />
<span><ng-container i18n>Edit</ng-container>...</span>
@ -92,7 +96,11 @@
<hr class="m-0" />
<button
mat-menu-item
[disabled]="element.accountCount > 0 || element.activityCount > 0"
[disabled]="
element.accountCount > 0 ||
element.activityCount > 0 ||
isSystemTag(element)
"
(click)="onDeleteTag(element.id)"
>
<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 { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
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 { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
@ -67,6 +71,7 @@ export class GfAdminTagComponent implements OnInit {
'activities',
'actions'
];
protected readonly isSystemTag = isSystemTag;
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected tags: Tag[];
protected readonly translate = translate;
@ -99,7 +104,7 @@ export class GfAdminTagComponent implements OnInit {
return id === params['tagId'];
});
if (tag) {
if (tag && !isSystemTag(tag)) {
this.openUpdateTagDialog(tag);
}
} else {

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

@ -251,7 +251,7 @@
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete User</span>
<span i18n>Delete</span>
</span>
</button>
</mat-menu>

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

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

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