Browse Source

Merge pull request #137 from dandevaud/mr/Upstream-2024-11-13

Mr/upstream 2024 11 13
pull/5027/head
dandevaud 8 months ago
committed by GitHub
parent
commit
6944d926fd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 123
      CHANGELOG.md
  2. 4
      apps/api/src/app/admin/admin.service.ts
  3. 4
      apps/api/src/app/auth/jwt.strategy.ts
  4. 2
      apps/api/src/app/benchmark/benchmark.service.ts
  5. 16
      apps/api/src/app/info/info.service.ts
  6. 250
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  7. 21
      apps/api/src/app/portfolio/current-rate.service.ts
  8. 25
      apps/api/src/app/portfolio/portfolio.controller.ts
  9. 16
      apps/api/src/app/portfolio/portfolio.service.ts
  10. 71
      apps/api/src/app/subscription/subscription.service.ts
  11. 4
      apps/api/src/app/symbol/symbol.controller.ts
  12. 12
      apps/api/src/app/symbol/symbol.service.ts
  13. 8
      apps/api/src/app/user/update-user-setting.dto.ts
  14. 53
      apps/api/src/app/user/user.service.ts
  15. 13
      apps/api/src/services/cron.service.ts
  16. 10
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  17. 11
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  18. 62
      apps/api/src/services/data-provider/data-provider.service.ts
  19. 11
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  20. 11
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  21. 10
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  22. 11
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  23. 6
      apps/api/src/services/data-provider/manual/manual.service.ts
  24. 8
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  25. 9
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  26. 5
      apps/api/src/services/prisma/prisma.module.ts
  27. 24
      apps/api/src/services/prisma/prisma.service.ts
  28. 4
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  29. 2
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  30. 1
      apps/client/src/app/app.component.html
  31. 13
      apps/client/src/app/app.component.ts
  32. 27
      apps/client/src/app/components/header/header.component.html
  33. 38
      apps/client/src/app/components/header/header.component.ts
  34. 19
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  35. 10
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  36. 2
      apps/client/src/app/pages/portfolio/fire/fire-page-routing.module.ts
  37. 93
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  38. 130
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  39. 2
      apps/client/src/app/pages/portfolio/fire/fire-page.module.ts
  40. 5
      apps/client/src/app/pages/portfolio/portfolio-page-routing.module.ts
  41. 7
      apps/client/src/app/pages/portfolio/portfolio-page.component.ts
  42. 21
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page-routing.module.ts
  43. 123
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
  44. 3
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.scss
  45. 150
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts
  46. 22
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.module.ts
  47. 24
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  48. 18
      apps/client/src/app/pages/pricing/pricing-page.html
  49. 4
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  50. 10
      apps/client/src/app/services/data.service.ts
  51. 14
      apps/client/src/app/services/user/user.service.ts
  52. 108
      apps/client/src/locales/messages.it.xlf
  53. 4
      docker/docker-compose.build.yml
  54. 5
      docker/docker-compose.dev.yml
  55. 4
      docker/docker-compose.yml
  56. 8
      libs/common/src/lib/interfaces/index.ts
  57. 6
      libs/common/src/lib/interfaces/info-item.interface.ts
  58. 4
      libs/common/src/lib/interfaces/lookup-item.interface.ts
  59. 5
      libs/common/src/lib/interfaces/responses/lookup-response.interface.ts
  60. 9
      libs/common/src/lib/interfaces/subscription-offer.interface.ts
  61. 6
      libs/common/src/lib/interfaces/subscription.interface.ts
  62. 2
      libs/common/src/lib/interfaces/user-settings.interface.ts
  63. 4
      libs/common/src/lib/interfaces/user.interface.ts
  64. 4
      libs/common/src/lib/types/index.ts
  65. 2
      libs/common/src/lib/types/subscription-offer-key.type.ts
  66. 4
      libs/common/src/lib/types/user-with-settings.type.ts
  67. 136
      libs/ui/src/lib/assistant/assistant.component.ts
  68. 28
      libs/ui/src/lib/assistant/assistant.html
  69. 2
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts
  70. 15
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
  71. 461
      package-lock.json
  72. 12
      package.json
  73. 2
      prisma/migrations/20241029190323_added_data_provider_ghostfolio_daily_requests_to_analytics/migration.sql
  74. 5
      prisma/migrations/20241102121004_added_last_request_at_to_analytics/migration.sql
  75. 15
      prisma/schema.prisma
  76. 2
      test/import/ok-derived-currency.json
  77. 6
      test/import/ok-vti-buy-long-history.json
  78. 8
      test/import/ok-without-accounts.json
  79. 10
      test/import/ok.json
  80. 2
      test/import/unavailable-exchange-rate.json

123
CHANGELOG.md

@ -94,6 +94,129 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## 2.115.0 - 2024-10-14 ## 2.115.0 - 2024-10-14
### Changed
- Extended the assistant by a holding selector
- Separated the _FIRE_ / _X-ray_ page
- Improved the language localization for Italian (`it`)
- Upgraded `ngx-skeleton-loader` from version `7.0.0` to `9.0.0`
## 2.122.0 - 2024-11-07
### Changed
- Upgraded `countries-list` from version `3.1.0` to `3.1.1`
### Fixed
- Fixed an issue with the algebraic sign in the chart of the holdings tab on the home page (experimental)
- Improved the exception handling in the user authorization service
- Disabled the caching of the benchmarks in the markets overview if sharing the _Fear & Greed Index_ (market mood) is enabled
## 2.121.1 - 2024-11-02
### Added
- Set the stack and container names in the `docker-compose` files (`docker-compose.yml`, `docker-compose.build.yml` and `docker-compose.dev.yml`)
### Changed
- Reverted the permissions (`chmod 0700`) on `entrypoint.sh` in the `Dockerfile`
- Upgraded the _Stripe_ dependencies
## 2.120.0 - 2024-10-30
### Added
- Added support for log levels (`LOG_LEVELS`) to conditionally log `prisma` query events (`debug` or `verbose`)
### Changed
- Restructured the resources page
- Renamed the static portfolio analysis rule from _Allocation Cluster Risk_ to _Economic Market Cluster Risk_ (Developed Markets and Emerging Markets)
- Improved the language localization for German (`de`)
- Switched the `consistent-generic-constructors` rule from `warn` to `error` in the `eslint` configuration
- Switched the `consistent-indexed-object-style` rule from `warn` to `off` in the `eslint` configuration
- Switched the `consistent-type-assertions` rule from `warn` to `error` in the `eslint` configuration
- Switched the `prefer-optional-chain` rule from `warn` to `error` in the `eslint` configuration
- Upgraded `Nx` from version `20.0.3` to `20.0.6`
## 2.119.0 - 2024-10-26
### Changed
- Switched the `consistent-type-definitions` rule from `warn` to `error` in the `eslint` configuration
- Switched the `no-empty-function` rule from `warn` to `error` in the `eslint` configuration
- Switched the `prefer-function-type` rule from `warn` to `error` in the `eslint` configuration
- Upgraded `prisma` from version `5.20.0` to `5.21.1`
### Fixed
- Fixed an issue with the X-axis scale of the dividend timeline on the analysis page
- Fixed an issue with the X-axis scale of the investment timeline on the analysis page
- Fixed an issue with the X-axis scale of the portfolio evolution chart on the analysis page
- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets)
- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets)
## 2.118.0 - 2024-10-23
### Added
- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets)
- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets)
- Added support for mutual funds in the _EOD Historical Data_ service
### Changed
- Improved the font colors of the chart of the holdings tab on the home page (experimental)
- Optimized the dialog sizes for mobile (full screen)
- Optimized the git-hook via `husky` to lint only affected projects before a commit
- Upgraded `angular` from version `18.1.1` to `18.2.8`
- Upgraded `Nx` from version `19.5.6` to `20.0.3`
### Fixed
- Fixed the warning `export was not found` in connection with `GetValuesParams`
- Quoted the password for the _Redis_ service `healthcheck` in the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`)
## 2.117.0 - 2024-10-19
### Added
- Added the logotype to the footer
- Added the data providers management to the admin control panel
### Changed
- Improved the backgrounds of the chart of the holdings tab on the home page (experimental)
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue in the carousel component for the testimonial section on the landing page
## 2.116.0 - 2024-10-17
### Added
- Extended the content of the _Self-Hosting_ section by the benchmarks concept for _Compare with..._ on the Frequently Asked Questions (FAQ) page
- Extended the content of the _Self-Hosting_ section by the benchmarks concept for _Markets_ on the Frequently Asked Questions (FAQ) page
- Set the permissions (`chmod 0700`) on `entrypoint.sh` in the `Dockerfile`
### Changed
- Improved the empty state in the benchmarks of the markets overview
- Disabled the text hover effect in the chart of the holdings tab on the home page (experimental)
- Improved the usability to customize the rule thresholds in the _X-ray_ section by introducing units (experimental)
- Switched to adjusted market prices (splits and dividends) in the get historical functionality of the _EOD Historical Data_ service
- Improved the language localization for German (`de`)
### Fixed
- Fixed the usage of the environment variable `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY`
## 2.115.0 - 2024-10-14
### Added ### Added
- Added the name to the tooltip of the chart of the holdings tab on the home page (experimental) - Added the name to the tooltip of the chart of the holdings tab on the home page (experimental)

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

@ -648,7 +648,7 @@ export class AdminService {
} }
private async getUsersWithAnalytics(): Promise<AdminUsers['users']> { private async getUsersWithAnalytics(): Promise<AdminUsers['users']> {
let orderBy: any = { let orderBy: Prisma.UserOrderByWithRelationInput = {
createdAt: 'desc' createdAt: 'desc'
}; };
let where: Prisma.UserWhereInput; let where: Prisma.UserWhereInput;
@ -656,7 +656,7 @@ export class AdminService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = { orderBy = {
Analytics: { Analytics: {
updatedAt: 'desc' lastRequestAt: 'desc'
} }
}; };
where = { where = {

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

@ -46,7 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
update: { update: {
country, country,
activityCount: { increment: 1 }, activityCount: { increment: 1 },
updatedAt: new Date() lastRequestAt: new Date()
}, },
where: { userId: user.id } where: { userId: user.id }
}); });
@ -60,7 +60,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
); );
} }
} catch (error) { } catch (error) {
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) { if (error?.getStatus?.() === StatusCodes.TOO_MANY_REQUESTS) {
throw error; throw error;
} else { } else {
throw new HttpException( throw new HttpException(

2
apps/api/src/app/benchmark/benchmark.service.ts

@ -437,7 +437,7 @@ export class BenchmarkService {
}; };
}); });
if (storeInCache) { if (!enableSharing && storeInCache) {
const expiration = addHours(new Date(), 2); const expiration = addHours(new Date(), 2);
await this.redisCacheService.set( await this.redisCacheService.set(

16
apps/api/src/app/info/info.service.ts

@ -23,10 +23,10 @@ import {
import { import {
InfoItem, InfoItem,
Statistics, Statistics,
Subscription SubscriptionOffer
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOffer } from '@ghostfolio/common/types'; import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
@ -101,7 +101,7 @@ export class InfoService {
isUserSignupEnabled, isUserSignupEnabled,
platforms, platforms,
statistics, statistics,
subscriptions subscriptionOffers
] = await Promise.all([ ] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(), this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(), this.getDemoAuthToken(),
@ -110,7 +110,7 @@ export class InfoService {
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getStatistics(), this.getStatistics(),
this.getSubscriptions() this.getSubscriptionOffers()
]); ]);
if (isUserSignupEnabled) { if (isUserSignupEnabled) {
@ -125,7 +125,7 @@ export class InfoService {
isReadOnlyMode, isReadOnlyMode,
platforms, platforms,
statistics, statistics,
subscriptions, subscriptionOffers,
baseCurrency: DEFAULT_CURRENCY, baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies() currencies: this.exchangeRateDataService.getCurrencies()
}; };
@ -142,7 +142,7 @@ export class InfoService {
}, },
{ {
Analytics: { Analytics: {
updatedAt: { lastRequestAt: {
gt: subDays(new Date(), aDays) gt: subDays(new Date(), aDays)
} }
} }
@ -314,8 +314,8 @@ export class InfoService {
return statistics; return statistics;
} }
private async getSubscriptions(): Promise<{ private async getSubscriptionOffers(): Promise<{
[offer in SubscriptionOffer]: Subscription; [offer in SubscriptionOfferKey]: SubscriptionOffer;
}> { }> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined; return undefined;

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

@ -162,10 +162,6 @@ export abstract class PortfolioCalculator {
this.snapshotPromise = this.initialize(); this.snapshotPromise = this.initialize();
} }
protected abstract calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot;
@LogPerformance @LogPerformance
public async computeSnapshot(): Promise<PortfolioSnapshot> { public async computeSnapshot(): Promise<PortfolioSnapshot> {
const lastTransactionPoint = last(this.transactionPoints); const lastTransactionPoint = last(this.transactionPoints);
@ -769,62 +765,6 @@ export abstract class PortfolioCalculator {
return { chart }; return { chart };
} }
@LogPerformance
public async getSnapshot() {
await this.snapshotPromise;
return this.snapshot;
}
public getStartDate() {
let firstAccountBalanceDate: Date;
let firstActivityDate: Date;
try {
const firstAccountBalanceDateString = first(
this.accountBalanceItems
)?.date;
firstAccountBalanceDate = firstAccountBalanceDateString
? parseDate(firstAccountBalanceDateString)
: new Date();
} catch (error) {
firstAccountBalanceDate = new Date();
}
try {
const firstActivityDateString = this.transactionPoints[0].date;
firstActivityDate = firstActivityDateString
? parseDate(firstActivityDateString)
: new Date();
} catch (error) {
firstActivityDate = new Date();
}
return min([firstAccountBalanceDate, firstActivityDate]);
}
protected abstract getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDateMap: { [date: string]: boolean };
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
} & AssetProfileIdentifier): SymbolMetrics;
public getTransactionPoints() {
return this.transactionPoints;
}
@LogPerformance @LogPerformance
public async getValuablesInBaseCurrency() { public async getValuablesInBaseCurrency() {
await this.snapshotPromise; await this.snapshotPromise;
@ -832,72 +772,11 @@ export abstract class PortfolioCalculator {
return this.snapshot.totalValuablesWithCurrencyEffect; return this.snapshot.totalValuablesWithCurrencyEffect;
} }
private getChartDateMap({ @LogPerformance
endDate, public async getSnapshot() {
startDate, await this.snapshotPromise;
step
}: {
endDate: Date;
startDate: Date;
step: number;
}): { [date: string]: true } {
// Create a map of all relevant chart dates:
// 1. Add transaction point dates
const chartDateMap = this.transactionPoints.reduce((result, { date }) => {
result[date] = true;
return result;
}, {});
// 2. Add dates between transactions respecting the specified step size
for (const date of eachDayOfInterval(
{ end: endDate, start: startDate },
{ step }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
if (step > 1) {
// Reduce the step size of last 90 days
for (const date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 90) },
{ step: 3 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
// Reduce the step size of last 30 days
for (const date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 30) },
{ step: 1 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
}
// Make sure the end date is present
chartDateMap[format(endDate, DATE_FORMAT)] = true;
// Make sure some key dates are present
for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) {
const { endDate: dateRangeEnd, startDate: dateRangeStart } =
getIntervalFromDateRange(dateRange);
if (
!isBefore(dateRangeStart, startDate) &&
!isAfter(dateRangeStart, endDate)
) {
chartDateMap[format(dateRangeStart, DATE_FORMAT)] = true;
}
if (
!isBefore(dateRangeEnd, startDate) &&
!isAfter(dateRangeEnd, endDate)
) {
chartDateMap[format(dateRangeEnd, DATE_FORMAT)] = true;
}
}
return chartDateMap; return this.snapshot;
} }
@LogPerformance @LogPerformance
@ -1120,4 +999,125 @@ export abstract class PortfolioCalculator {
await this.initialize(); await this.initialize();
} }
} }
public getStartDate() {
let firstAccountBalanceDate: Date;
let firstActivityDate: Date;
try {
const firstAccountBalanceDateString = first(
this.accountBalanceItems
)?.date;
firstAccountBalanceDate = firstAccountBalanceDateString
? parseDate(firstAccountBalanceDateString)
: new Date();
} catch (error) {
firstAccountBalanceDate = new Date();
}
try {
const firstActivityDateString = this.transactionPoints[0].date;
firstActivityDate = firstActivityDateString
? parseDate(firstActivityDateString)
: new Date();
} catch (error) {
firstActivityDate = new Date();
}
return min([firstAccountBalanceDate, firstActivityDate]);
}
public getTransactionPoints() {
return this.transactionPoints;
}
private getChartDateMap({
endDate,
startDate,
step
}: {
endDate: Date;
startDate: Date;
step: number;
}): { [date: string]: true } {
// Create a map of all relevant chart dates:
// 1. Add transaction point dates
const chartDateMap = this.transactionPoints.reduce((result, { date }) => {
result[date] = true;
return result;
}, {});
// 2. Add dates between transactions respecting the specified step size
for (const date of eachDayOfInterval(
{ end: endDate, start: startDate },
{ step }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
if (step > 1) {
// Reduce the step size of last 90 days
for (const date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 90) },
{ step: 3 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
// Reduce the step size of last 30 days
for (const date of eachDayOfInterval(
{ end: endDate, start: subDays(endDate, 30) },
{ step: 1 }
)) {
chartDateMap[format(date, DATE_FORMAT)] = true;
}
}
// Make sure the end date is present
chartDateMap[format(endDate, DATE_FORMAT)] = true;
// Make sure some key dates are present
for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) {
const { endDate: dateRangeEnd, startDate: dateRangeStart } =
getIntervalFromDateRange(dateRange);
if (
!isBefore(dateRangeStart, startDate) &&
!isAfter(dateRangeStart, endDate)
) {
chartDateMap[format(dateRangeStart, DATE_FORMAT)] = true;
}
if (
!isBefore(dateRangeEnd, startDate) &&
!isAfter(dateRangeEnd, endDate)
) {
chartDateMap[format(dateRangeEnd, DATE_FORMAT)] = true;
}
}
return chartDateMap;
}
protected abstract getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDateMap: { [date: string]: boolean };
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
} & AssetProfileIdentifier): SymbolMetrics;
protected abstract calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot;
} }

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

@ -52,27 +52,24 @@ export class CurrentRateService {
.then((dataResultProvider) => { .then((dataResultProvider) => {
const result: GetValueObject[] = []; const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) { for (const { dataSource, symbol } of dataGatheringItems) {
if ( if (dataResultProvider?.[symbol]?.dataProviderInfo) {
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
) {
dataProviderInfos.push( dataProviderInfos.push(
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo dataResultProvider[symbol].dataProviderInfo
); );
} }
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) { if (dataResultProvider?.[symbol]?.marketPrice) {
result.push({ result.push({
dataSource: dataGatheringItem.dataSource, dataSource,
symbol,
date: today, date: today,
marketPrice: marketPrice: dataResultProvider?.[symbol]?.marketPrice
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice,
symbol: dataGatheringItem.symbol
}); });
} else { } else {
quoteErrors.push({ quoteErrors.push({
dataSource: dataGatheringItem.dataSource, dataSource,
symbol: dataGatheringItem.symbol symbol
}); });
} }
} }

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

@ -77,12 +77,15 @@ export class PortfolioController {
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('withMarkets') withMarketsParam = 'false' @Query('withMarkets') withMarketsParam = 'false'
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
@ -98,6 +101,8 @@ export class PortfolioController {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });
@ -310,17 +315,22 @@ export class PortfolioController {
@Get('dividends') @Get('dividends')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getDividends( public async getDividends(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('groupBy') groupBy?: GroupBy, @Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioDividends> { ): Promise<PortfolioDividends> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });
@ -377,21 +387,26 @@ export class PortfolioController {
@Get('holdings') @Get('holdings')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getHoldings( public async getHoldings(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('holdingType') filterByHoldingType?: string, @Query('holdingType') filterByHoldingType?: string,
@Query('query') filterBySearchQuery?: string, @Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioHoldingsResponse> { ): Promise<PortfolioHoldingsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterByHoldingType, filterByHoldingType,
filterBySearchQuery, filterBySearchQuery,
filterBySymbol,
filterByTags filterByTags
}); });
@ -407,17 +422,22 @@ export class PortfolioController {
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getInvestments( public async getInvestments(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('groupBy') groupBy?: GroupBy, @Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> { ): Promise<PortfolioInvestments> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });
@ -472,6 +492,7 @@ export class PortfolioController {
@Get('performance') @Get('performance')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(PerformanceLoggingInterceptor) @UseInterceptors(PerformanceLoggingInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2') @Version('2')
@LogPerformance @LogPerformance
@ -479,7 +500,9 @@ export class PortfolioController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccounts = false, @Query('withExcludedAccounts') withExcludedAccounts = false,
@Query('timeWeightedPerformance') calculateTimeWeightedPerformance = false @Query('timeWeightedPerformance') calculateTimeWeightedPerformance = false
@ -487,6 +510,8 @@ export class PortfolioController {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });

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

@ -420,15 +420,16 @@ export class PortfolioService {
); );
} }
const dataGatheringItems = positions.map(({ dataSource, symbol }) => { const assetProfileIdentifiers = positions.map(({ dataSource, symbol }) => {
return { return {
dataSource, dataSource,
symbol symbol
}; };
}); });
const symbolProfiles = const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
await this.symbolProfileService.getSymbolProfiles(dataGatheringItems); assetProfileIdentifiers
);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) { for (const symbolProfile of symbolProfiles) {
@ -868,7 +869,7 @@ export class PortfolioService {
if (isEmpty(historicalData)) { if (isEmpty(historicalData)) {
try { try {
historicalData = await this.dataProviderService.getHistoricalRaw({ historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [ assetProfileIdentifiers: [
{ dataSource: DataSource.YAHOO, symbol: aSymbol } { dataSource: DataSource.YAHOO, symbol: aSymbol }
], ],
from: portfolioStart, from: portfolioStart,
@ -975,7 +976,7 @@ export class PortfolioService {
return !quantity.eq(0); return !quantity.eq(0);
}); });
const dataGatheringItems = positions.map(({ dataSource, symbol }) => { const assetProfileIdentifiers = positions.map(({ dataSource, symbol }) => {
return { return {
dataSource, dataSource,
symbol symbol
@ -983,7 +984,10 @@ export class PortfolioService {
}); });
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes({ user, items: dataGatheringItems }), this.dataProviderService.getQuotes({
user,
items: assetProfileIdentifiers
}),
this.symbolProfileService.getSymbolProfiles( this.symbolProfileService.getSymbolProfiles(
positions.map(({ dataSource, symbol }) => { positions.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };

71
apps/api/src/app/subscription/subscription.service.ts

@ -1,8 +1,16 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { SubscriptionOffer, UserWithSettings } from '@ghostfolio/common/types'; import { SubscriptionOffer } from '@ghostfolio/common/interfaces';
import {
SubscriptionOfferKey,
UserWithSettings
} from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -17,14 +25,17 @@ export class SubscriptionService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) { ) {
this.stripe = new Stripe( if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.configurationService.get('STRIPE_SECRET_KEY'), this.stripe = new Stripe(
{ this.configurationService.get('STRIPE_SECRET_KEY'),
apiVersion: '2024-04-10' {
} apiVersion: '2024-09-30.acacia'
); }
);
}
} }
public async createCheckoutSession({ public async createCheckoutSession({
@ -36,6 +47,18 @@ export class SubscriptionService {
priceId: string; priceId: string;
user: UserWithSettings; user: UserWithSettings;
}) { }) {
const subscriptionOffers: {
[offer in SubscriptionOfferKey]: SubscriptionOffer;
} =
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
{};
const subscriptionOffer = Object.values(subscriptionOffers).find(
(subscriptionOffer) => {
return subscriptionOffer.priceId === priceId;
}
);
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = { const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/${ cancel_url: `${this.configurationService.get('ROOT_URL')}/${
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE
@ -47,6 +70,13 @@ export class SubscriptionService {
quantity: 1 quantity: 1
} }
], ],
locale:
(user.Settings?.settings
?.language as Stripe.Checkout.SessionCreateParams.Locale) ??
DEFAULT_LANGUAGE_CODE,
metadata: subscriptionOffer
? { subscriptionOffer: JSON.stringify(subscriptionOffer) }
: {},
mode: 'payment', mode: 'payment',
payment_method_types: ['card'], payment_method_types: ['card'],
success_url: `${this.configurationService.get( success_url: `${this.configurationService.get(
@ -73,17 +103,25 @@ export class SubscriptionService {
public async createSubscription({ public async createSubscription({
duration = '1 year', duration = '1 year',
durationExtension,
price, price,
userId userId
}: { }: {
duration?: StringValue; duration?: StringValue;
durationExtension?: StringValue;
price: number; price: number;
userId: string; userId: string;
}) { }) {
let expiresAt = addMilliseconds(new Date(), ms(duration));
if (durationExtension) {
expiresAt = addMilliseconds(expiresAt, ms(durationExtension));
}
await this.prismaService.subscription.create({ await this.prismaService.subscription.create({
data: { data: {
expiresAt,
price, price,
expiresAt: addMilliseconds(new Date(), ms(duration)),
User: { User: {
connect: { connect: {
id: userId id: userId
@ -95,10 +133,21 @@ export class SubscriptionService {
public async createSubscriptionViaStripe(aCheckoutSessionId: string) { public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
try { try {
let durationExtension: StringValue;
const session = const session =
await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId); await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId);
const subscriptionOffer: SubscriptionOffer = JSON.parse(
session.metadata.subscriptionOffer ?? '{}'
);
if (subscriptionOffer) {
durationExtension = subscriptionOffer.durationExtension;
}
await this.createSubscription({ await this.createSubscription({
durationExtension,
price: session.amount_total / 100, price: session.amount_total / 100,
userId: session.client_reference_id userId: session.client_reference_id
}); });
@ -121,7 +170,7 @@ export class SubscriptionService {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b; return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
}); });
let offer: SubscriptionOffer = price ? 'renewal' : 'default'; let offer: SubscriptionOfferKey = price ? 'renewal' : 'default';
if (isBefore(createdAt, parseDate('2023-01-01'))) { if (isBefore(createdAt, parseDate('2023-01-01'))) {
offer = 'renewal-early-bird-2023'; offer = 'renewal-early-bird-2023';

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

@ -2,6 +2,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { LookupResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -21,7 +22,6 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash'; import { isDate, isEmpty } from 'lodash';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
@ -41,7 +41,7 @@ export class SymbolController {
public async lookupSymbol( public async lookupSymbol(
@Query('includeIndices') includeIndicesParam = 'false', @Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = '' @Query('query') query = ''
): Promise<{ items: LookupItem[] }> { ): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true'; const includeIndices = includeIndicesParam === 'true';
try { try {

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

@ -5,13 +5,15 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; import {
HistoricalDataItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@Injectable() @Injectable()
@ -84,7 +86,7 @@ export class SymbolService {
try { try {
historicalData = await this.dataProviderService.getHistoricalRaw({ historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }], assetProfileIdentifiers: [{ dataSource, symbol }],
from: date, from: date,
to: date to: date
}); });
@ -104,8 +106,8 @@ export class SymbolService {
includeIndices?: boolean; includeIndices?: boolean;
query: string; query: string;
user: UserWithSettings; user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> { }): Promise<LookupResponse> {
const results: { items: LookupItem[] } = { items: [] }; const results: LookupResponse = { items: [] };
if (!query) { if (!query) {
return results; return results;

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

@ -67,6 +67,14 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
'filters.assetClasses'?: string[]; 'filters.assetClasses'?: string[];
@IsString()
@IsOptional()
'filters.dataSource'?: string;
@IsString()
@IsOptional()
'filters.symbol'?: string;
@IsArray() @IsArray()
@IsOptional() @IsOptional()
'filters.tags'?: string[]; 'filters.tags'?: string[];

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

@ -37,7 +37,7 @@ import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays, subDays } from 'date-fns';
import { sortBy, without } from 'lodash'; import { sortBy, without } from 'lodash';
const crypto = require('crypto'); const crypto = require('crypto');
@ -60,6 +60,13 @@ export class UserService {
return this.prismaService.user.count(args); return this.prismaService.user.count(args);
} }
public createAccessToken(password: string, salt: string): string {
const hash = crypto.createHmac('sha512', salt);
hash.update(password);
return hash.digest('hex');
}
public async getUser( public async getUser(
{ Account, id, permissions, Settings, subscription }: UserWithSettings, { Account, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale aLocale = locale
@ -358,13 +365,6 @@ export class UserService {
}); });
} }
public createAccessToken(password: string, salt: string): string {
const hash = crypto.createHmac('sha512', salt);
hash.update(password);
return hash.digest('hex');
}
public async createUser({ public async createUser({
data data
}: { }: {
@ -426,17 +426,6 @@ export class UserService {
return user; return user;
} }
public async updateUser(params: {
where: Prisma.UserWhereUniqueInput;
data: Prisma.UserUpdateInput;
}): Promise<User> {
const { where, data } = params;
return this.prismaService.user.update({
data,
where
});
}
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> { public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
try { try {
await this.prismaService.access.deleteMany({ await this.prismaService.access.deleteMany({
@ -473,6 +462,32 @@ export class UserService {
}); });
} }
public async resetAnalytics() {
return this.prismaService.analytics.updateMany({
data: {
dataProviderGhostfolioDailyRequests: 0
},
where: {
updatedAt: {
gte: subDays(new Date(), 1)
}
}
});
}
public async updateUser({
data,
where
}: {
data: Prisma.UserUpdateInput;
where: Prisma.UserWhereUniqueInput;
}): Promise<User> {
return this.prismaService.user.update({
data,
where
});
}
public async updateUserSetting({ public async updateUserSetting({
emitPortfolioChangedEvent, emitPortfolioChangedEvent,
userId, userId,

13
apps/api/src/services/cron.service.ts

@ -1,3 +1,4 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
@ -9,6 +10,7 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { ConfigurationService } from './configuration/configuration.service';
import { ExchangeRateDataService } from './exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from './exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from './property/property.service'; import { PropertyService } from './property/property.service';
import { DataGatheringService } from './queues/data-gathering/data-gathering.service'; import { DataGatheringService } from './queues/data-gathering/data-gathering.service';
@ -19,10 +21,12 @@ export class CronService {
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0'; private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly twitterBotService: TwitterBotService private readonly twitterBotService: TwitterBotService,
private readonly userService: UserService
) {} ) {}
@Cron(CronExpression.EVERY_HOUR) @Cron(CronExpression.EVERY_HOUR)
@ -42,6 +46,13 @@ export class CronService {
this.twitterBotService.tweetFearAndGreedIndex(); this.twitterBotService.tweetFearAndGreedIndex();
} }
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
public async runEveryDayAtMidnight() {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.userService.resetAnalytics();
}
}
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME) @Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
public async runEverySundayAtTwelvePm() { public async runEverySundayAtTwelvePm() {
if (await this.isDataGatheringEnabled()) { if (await this.isDataGatheringEnabled()) {

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
@ -12,7 +11,10 @@ import {
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@ -119,9 +121,7 @@ export class AlphaVantageService implements DataProviderInterface {
return undefined; return undefined;
} }
public async search({ public async search({ query }: GetSearchParams): Promise<LookupResponse> {
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(query); const result = await this.alphaVantage.data.search(query);
return { return {

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
@ -13,7 +12,11 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
@ -221,9 +224,7 @@ export class CoinGeckoService implements DataProviderInterface {
return 'bitcoin'; return 'bitcoin';
} }
public async search({ public async search({ query }: GetSearchParams): Promise<LookupResponse> {
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {

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

@ -1,5 +1,4 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -21,7 +20,11 @@ import {
getStartOfUtcDate, getStartOfUtcDate,
isDerivedCurrency isDerivedCurrency
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
@ -89,11 +92,11 @@ export class DataProviderService {
const promises = []; const promises = [];
for (const [dataSource, dataGatheringItems] of Object.entries( for (const [dataSource, assetProfileIdentifiers] of Object.entries(
itemsGroupedByDataSource itemsGroupedByDataSource
)) { )) {
const symbols = dataGatheringItems.map((dataGatheringItem) => { const symbols = assetProfileIdentifiers.map(({ symbol }) => {
return dataGatheringItem.symbol; return symbol;
}); });
for (const symbol of symbols) { for (const symbol of symbols) {
@ -240,11 +243,11 @@ export class DataProviderService {
} }
public async getHistoricalRaw({ public async getHistoricalRaw({
dataGatheringItems, assetProfileIdentifiers,
from, from,
to to
}: { }: {
dataGatheringItems: AssetProfileIdentifier[]; assetProfileIdentifiers: AssetProfileIdentifier[];
from: Date; from: Date;
to: Date; to: Date;
}): Promise<{ }): Promise<{
@ -253,25 +256,32 @@ export class DataProviderService {
for (const { currency, rootCurrency } of DERIVED_CURRENCIES) { for (const { currency, rootCurrency } of DERIVED_CURRENCIES) {
if ( if (
this.hasCurrency({ this.hasCurrency({
dataGatheringItems, assetProfileIdentifiers,
currency: `${DEFAULT_CURRENCY}${currency}` currency: `${DEFAULT_CURRENCY}${currency}`
}) })
) { ) {
// Skip derived currency // Skip derived currency
dataGatheringItems = dataGatheringItems.filter(({ symbol }) => { assetProfileIdentifiers = assetProfileIdentifiers.filter(
return symbol !== `${DEFAULT_CURRENCY}${currency}`; ({ symbol }) => {
}); return symbol !== `${DEFAULT_CURRENCY}${currency}`;
}
);
// Add root currency // Add root currency
dataGatheringItems.push({ assetProfileIdentifiers.push({
dataSource: this.getDataSourceForExchangeRates(), dataSource: this.getDataSourceForExchangeRates(),
symbol: `${DEFAULT_CURRENCY}${rootCurrency}` symbol: `${DEFAULT_CURRENCY}${rootCurrency}`
}); });
} }
} }
dataGatheringItems = uniqWith(dataGatheringItems, (obj1, obj2) => { assetProfileIdentifiers = uniqWith(
return obj1.dataSource === obj2.dataSource && obj1.symbol === obj2.symbol; assetProfileIdentifiers,
}); (obj1, obj2) => {
return (
obj1.dataSource === obj2.dataSource && obj1.symbol === obj2.symbol
);
}
);
const result: { const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
@ -281,7 +291,7 @@ export class DataProviderService {
data: { [date: string]: IDataProviderHistoricalResponse }; data: { [date: string]: IDataProviderHistoricalResponse };
symbol: string; symbol: string;
}>[] = []; }>[] = [];
for (const { dataSource, symbol } of dataGatheringItems) { for (const { dataSource, symbol } of assetProfileIdentifiers) {
const dataProvider = this.getDataProvider(dataSource); const dataProvider = this.getDataProvider(dataSource);
if (dataProvider.canHandle(symbol)) { if (dataProvider.canHandle(symbol)) {
if (symbol === `${DEFAULT_CURRENCY}USX`) { if (symbol === `${DEFAULT_CURRENCY}USX`) {
@ -417,7 +427,7 @@ export class DataProviderService {
const promises: Promise<any>[] = []; const promises: Promise<any>[] = [];
for (const [dataSource, dataGatheringItems] of Object.entries( for (const [dataSource, assetProfileIdentifiers] of Object.entries(
itemsGroupedByDataSource itemsGroupedByDataSource
)) { )) {
const dataProvider = this.getDataProvider(DataSource[dataSource]); const dataProvider = this.getDataProvider(DataSource[dataSource]);
@ -430,7 +440,7 @@ export class DataProviderService {
continue; continue;
} }
const symbols = dataGatheringItems const symbols = assetProfileIdentifiers
.filter(({ symbol }) => { .filter(({ symbol }) => {
return !isDerivedCurrency(getCurrencyFromSymbol(symbol)); return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
}) })
@ -594,9 +604,9 @@ export class DataProviderService {
includeIndices?: boolean; includeIndices?: boolean;
query: string; query: string;
user: UserWithSettings; user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> { }): Promise<LookupResponse> {
const promises: Promise<{ items: LookupItem[] }>[] = [];
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
const promises: Promise<LookupResponse>[] = [];
if (query?.length < 2) { if (query?.length < 2) {
return { items: lookupItems }; return { items: lookupItems };
@ -626,9 +636,9 @@ export class DataProviderService {
}); });
const filteredItems = lookupItems const filteredItems = lookupItems
.filter((lookupItem) => { .filter(({ currency }) => {
// Only allow symbols with supported currency // Only allow symbols with supported currency
return lookupItem.currency ? true : false; return currency ? true : false;
}) })
.sort(({ name: name1 }, { name: name2 }) => { .sort(({ name: name1 }, { name: name2 }) => {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase()); return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
@ -654,13 +664,13 @@ export class DataProviderService {
} }
private hasCurrency({ private hasCurrency({
currency, assetProfileIdentifiers,
dataGatheringItems currency
}: { }: {
assetProfileIdentifiers: AssetProfileIdentifier[];
currency: string; currency: string;
dataGatheringItems: AssetProfileIdentifier[];
}) { }) {
return dataGatheringItems.some(({ dataSource, symbol }) => { return assetProfileIdentifiers.some(({ dataSource, symbol }) => {
return ( return (
dataSource === this.getDataSourceForExchangeRates() && dataSource === this.getDataSourceForExchangeRates() &&
symbol === currency symbol === currency

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
@ -17,7 +16,11 @@ import {
REPLACE_NAME_PARTS REPLACE_NAME_PARTS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types'; import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -317,9 +320,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
return 'AAPL.US'; return 'AAPL.US';
} }
public async search({ public async search({ query }: GetSearchParams): Promise<LookupResponse> {
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
const searchResult = await this.getSearchResult(query); const searchResult = await this.getSearchResult(query);
return { return {

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
@ -13,7 +12,11 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@ -169,9 +172,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return 'AAPL'; return 'AAPL';
} }
public async search({ public async search({ query }: GetSearchParams): Promise<LookupResponse> {
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
@ -14,7 +13,10 @@ import {
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@ -157,9 +159,7 @@ export class GoogleSheetsService implements DataProviderInterface {
return 'INDEXSP:.INX'; return 'INDEXSP:.INX';
} }
public async search({ public async search({ query }: GetSearchParams): Promise<LookupResponse> {
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({ const items = await this.prismaService.symbolProfile.findMany({
select: { select: {
assetClass: true, assetClass: true,

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

@ -1,9 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@ -44,10 +46,7 @@ export interface DataProviderInterface {
getTestSymbol(): string; getTestSymbol(): string;
search({ search({ includeIndices, query }: GetSearchParams): Promise<LookupResponse>;
includeIndices,
query
}: GetSearchParams): Promise<{ items: LookupItem[] }>;
} }
export interface GetDividendsParams { export interface GetDividendsParams {

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
@ -21,6 +20,7 @@ import {
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
DataProviderInfo, DataProviderInfo,
LookupResponse,
ScraperConfiguration ScraperConfiguration
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -227,9 +227,7 @@ export class ManualService implements DataProviderInterface {
return undefined; return undefined;
} }
public async search({ public async search({ query }: GetSearchParams): Promise<LookupResponse> {
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
let items = await this.prismaService.symbolProfile.findMany({ let items = await this.prismaService.symbolProfile.findMany({
select: { select: {
assetClass: true, assetClass: true,

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
@ -13,7 +12,10 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@ -121,7 +123,7 @@ export class RapidApiService implements DataProviderInterface {
return undefined; return undefined;
} }
public async search({}: GetSearchParams): Promise<{ items: LookupItem[] }> { public async search({}: GetSearchParams): Promise<LookupResponse> {
return { items: [] }; return { items: [] };
} }

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

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { import {
@ -14,7 +13,11 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@ -224,7 +227,7 @@ export class YahooFinanceService implements DataProviderInterface {
public async search({ public async search({
includeIndices = false, includeIndices = false,
query query
}: GetSearchParams): Promise<{ items: LookupItem[] }> { }: GetSearchParams): Promise<LookupResponse> {
const items: LookupItem[] = []; const items: LookupItem[] = [];
try { try {

5
apps/api/src/services/prisma/prisma.module.ts

@ -1,9 +1,10 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Module({ @Module({
providers: [PrismaService], exports: [PrismaService],
exports: [PrismaService] providers: [ConfigService, PrismaService]
}) })
export class PrismaModule {} export class PrismaModule {}

24
apps/api/src/services/prisma/prisma.service.ts

@ -1,16 +1,38 @@
import { import {
Injectable, Injectable,
Logger, Logger,
LogLevel,
OnModuleDestroy, OnModuleDestroy,
OnModuleInit OnModuleInit
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { ConfigService } from '@nestjs/config';
import { Prisma, PrismaClient } from '@prisma/client';
@Injectable() @Injectable()
export class PrismaService export class PrismaService
extends PrismaClient extends PrismaClient
implements OnModuleInit, OnModuleDestroy implements OnModuleInit, OnModuleDestroy
{ {
public constructor(configService: ConfigService) {
let customLogLevels: LogLevel[];
try {
customLogLevels = JSON.parse(
configService.get<string>('LOG_LEVELS')
) as LogLevel[];
} catch {}
const log: Prisma.LogDefinition[] =
customLogLevels?.includes('debug') || customLogLevels?.includes('verbose')
? [{ emit: 'stdout', level: 'query' }]
: [];
super({
log,
errorFormat: 'colorless'
});
}
public async onModuleInit() { public async onModuleInit() {
try { try {
await this.$connect(); await this.$connect();

4
apps/api/src/services/queues/data-gathering/data-gathering.processor.ts

@ -96,7 +96,7 @@ export class DataGatheringProcessor {
); );
const historicalData = await this.dataProviderService.getHistoricalRaw({ const historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }], assetProfileIdentifiers: [{ dataSource, symbol }],
from: currentDate, from: currentDate,
to: new Date() to: new Date()
}); });
@ -214,7 +214,7 @@ export class DataGatheringProcessor {
dates = dates.filter((d) => !marketData.some((md) => isEqual(md, d))); dates = dates.filter((d) => !marketData.some((md) => isEqual(md, d)));
const historicalData = await this.dataProviderService.getHistoricalRaw({ const historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }], assetProfileIdentifiers: [{ dataSource, symbol }],
from: firstEntry.date, from: firstEntry.date,
to: new Date() to: new Date()
}); });

2
apps/api/src/services/queues/data-gathering/data-gathering.service.ts

@ -142,7 +142,7 @@ export class DataGatheringService {
}) { }) {
try { try {
const historicalData = await this.dataProviderService.getHistoricalRaw({ const historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }], assetProfileIdentifiers: [{ dataSource, symbol }],
from: date, from: date,
to: date to: date
}); });

1
apps/client/src/app/app.component.html

@ -33,6 +33,7 @@
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToChangeDateRange]="hasPermissionToChangeDateRange" [hasPermissionToChangeDateRange]="hasPermissionToChangeDateRange"
[hasPermissionToChangeFilters]="hasPermissionToChangeFilters" [hasPermissionToChangeFilters]="hasPermissionToChangeFilters"
[hasPromotion]="hasPromotion"
[hasTabs]="hasTabs" [hasTabs]="hasTabs"
[info]="info" [info]="info"
[pageTitle]="pageTitle" [pageTitle]="pageTitle"

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

@ -57,6 +57,7 @@ export class AppComponent implements OnDestroy, OnInit {
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToChangeDateRange: boolean; public hasPermissionToChangeDateRange: boolean;
public hasPermissionToChangeFilters: boolean; public hasPermissionToChangeFilters: boolean;
public hasPromotion = false;
public hasTabs = false; public hasTabs = false;
public info: InfoItem; public info: InfoItem;
public pageTitle: string; public pageTitle: string;
@ -136,6 +137,10 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.enableFearAndGreedIndex permissions.enableFearAndGreedIndex
); );
this.hasPromotion =
!!this.info?.subscriptionOffers?.default?.coupon ||
!!this.info?.subscriptionOffers?.default?.durationExtension;
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -231,6 +236,14 @@ export class AppComponent implements OnDestroy, OnInit {
this.hasInfoMessage = this.hasInfoMessage =
this.canCreateAccount || !!this.user?.systemMessage; this.canCreateAccount || !!this.user?.systemMessage;
this.hasPromotion =
!!this.info?.subscriptionOffers?.[
this.user?.subscription?.offer ?? 'default'
]?.coupon ||
!!this.info?.subscriptionOffers?.[
this.user?.subscription?.offer ?? 'default'
]?.durationExtension;
this.initializeTheme(this.user?.settings.colorScheme); this.initializeTheme(this.user?.settings.colorScheme);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

27
apps/client/src/app/components/header/header.component.html

@ -88,15 +88,20 @@
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === routePricing, 'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === routePricing 'text-decoration-underline': currentRoute === routePricing
}" }"
[routerLink]="routerLinkPricing" [routerLink]="routerLinkPricing"
>Pricing</a
> >
<span class="align-items-center d-flex">
<span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) {
<span class="badge badge-warning ml-1">%</span>
}
</span>
</a>
</li> </li>
} }
<li class="list-inline-item"> <li class="list-inline-item">
@ -290,12 +295,17 @@
) { ) {
<a <a
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === routePricing }" [ngClass]="{ 'font-weight-bold': currentRoute === routePricing }"
[routerLink]="routerLinkPricing" [routerLink]="routerLinkPricing"
>Pricing</a
> >
<span class="align-items-center d-flex">
<span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) {
<span class="badge badge-warning ml-1">%</span>
}
</span>
</a>
} }
<a <a
class="d-flex d-sm-none" class="d-flex d-sm-none"
@ -358,15 +368,20 @@
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-sm-block" class="d-sm-block"
i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === routePricing, 'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === routePricing 'text-decoration-underline': currentRoute === routePricing
}" }"
[routerLink]="routerLinkPricing" [routerLink]="routerLinkPricing"
>Pricing</a
> >
<span class="align-items-center d-flex">
<span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) {
<span class="badge badge-warning ml-1">%</span>
}
</span>
</a>
</li> </li>
} }
@if (hasPermissionToAccessFearAndGreedIndex) { @if (hasPermissionToAccessFearAndGreedIndex) {

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

@ -58,6 +58,7 @@ export class HeaderComponent implements OnChanges {
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToChangeDateRange: boolean; @Input() hasPermissionToChangeDateRange: boolean;
@Input() hasPermissionToChangeFilters: boolean; @Input() hasPermissionToChangeFilters: boolean;
@Input() hasPromotion: boolean;
@Input() hasTabs: boolean; @Input() hasTabs: boolean;
@Input() info: InfoItem; @Input() info: InfoItem;
@Input() pageTitle: string; @Input() pageTitle: string;
@ -174,24 +175,18 @@ export class HeaderComponent implements OnChanges {
const userSetting: UpdateUserSettingDto = {}; const userSetting: UpdateUserSettingDto = {};
for (const filter of filters) { for (const filter of filters) {
const filtersType = this.getFilterType(filter.type); if (filter.type === 'ACCOUNT') {
userSetting['filters.accounts'] = filter.id ? [filter.id] : null;
const userFilters = filters } else if (filter.type === 'ASSET_CLASS') {
.filter((f) => f.type === filter.type && filter.id) userSetting['filters.assetClasses'] = filter.id ? [filter.id] : null;
.map((f) => f.id); } else if (filter.type === 'DATA_SOURCE') {
userSetting['filters.dataSource'] = filter.id ? filter.id : null;
userSetting[`filters.${filtersType}`] = userFilters.length } else if (filter.type === 'SYMBOL') {
? userFilters userSetting['filters.symbol'] = filter.id ? filter.id : null;
: null; } else if (filter.type === 'TAG') {
userSetting['filters.tags'] = filter.id ? [filter.id] : null;
}
} }
['ACCOUNT', 'ASSET_CLASS', 'TAG']
.filter(
(fitlerType) =>
!filters.some((f: Filter) => f.type.toString() === fitlerType)
)
.forEach((filterType) => {
userSetting[`filters.${this.getFilterType(filterType)}`] = null;
});
this.dataService this.dataService
.putUserSetting(userSetting) .putUserSetting(userSetting)
@ -285,13 +280,4 @@ export class HeaderComponent implements OnChanges {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private getFilterType(filterType: string) {
if (filterType === 'ACCOUNT') {
return 'accounts';
} else if (filterType === 'ASSET_CLASS') {
return 'assetClasses';
} else if (filterType === 'TAG') {
return 'tags';
}
}
} }

19
apps/client/src/app/components/user-account-membership/user-account-membership.component.ts

@ -16,6 +16,7 @@ import {
MatSnackBarRef, MatSnackBarRef,
TextOnlySnackBar TextOnlySnackBar
} from '@angular/material/snack-bar'; } from '@angular/material/snack-bar';
import { StringValue } from 'ms';
import { StripeService } from 'ngx-stripe'; import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators'; import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@ -31,6 +32,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
public coupon: number; public coupon: number;
public couponId: string; public couponId: string;
public defaultDateFormat: string; public defaultDateFormat: string;
public durationExtension: StringValue;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public price: number; public price: number;
@ -51,7 +53,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
private stripeService: StripeService, private stripeService: StripeService,
private userService: UserService private userService: UserService
) { ) {
const { baseCurrency, globalPermissions, subscriptions } = const { baseCurrency, globalPermissions, subscriptionOffers } =
this.dataService.fetchInfo(); this.dataService.fetchInfo();
this.baseCurrency = baseCurrency; this.baseCurrency = baseCurrency;
@ -76,11 +78,18 @@ export class UserAccountMembershipComponent implements OnDestroy {
permissions.updateUserSettings permissions.updateUserSettings
); );
this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon; this.coupon =
subscriptionOffers?.[this.user.subscription.offer]?.coupon;
this.couponId = this.couponId =
subscriptions?.[this.user.subscription.offer]?.couponId; subscriptionOffers?.[this.user.subscription.offer]?.couponId;
this.price = subscriptions?.[this.user.subscription.offer]?.price; this.durationExtension =
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId; subscriptionOffers?.[
this.user.subscription.offer
]?.durationExtension;
this.price =
subscriptionOffers?.[this.user.subscription.offer]?.price;
this.priceId =
subscriptionOffers?.[this.user.subscription.offer]?.priceId;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }

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

@ -34,6 +34,16 @@
&nbsp;<span i18n>per year</span> &nbsp;<span i18n>per year</span>
</div> </div>
} }
@if (durationExtension) {
<div class="mt-1 text-center">
<div
class="badge badge-pill badge-warning font-weight-normal px-2 py-1"
>
<strong>Limited Offer!</strong> Get
{{ durationExtension }} extra
</div>
</div>
}
} }
<div class="align-items-center d-flex justify-content-center mt-4"> <div class="align-items-center d-flex justify-content-center mt-4">
@if (!user?.subscription?.expiresAt) { @if (!user?.subscription?.expiresAt) {

2
apps/client/src/app/pages/portfolio/fire/fire-page-routing.module.ts

@ -10,7 +10,7 @@ const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: FirePageComponent, component: FirePageComponent,
path: '', path: '',
title: $localize`FIRE` title: 'FIRE'
} }
]; ];

93
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -1,12 +1,7 @@
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import { User } from '@ghostfolio/common/interfaces';
PortfolioReport,
PortfolioReportRule,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
@ -21,18 +16,11 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './fire-page.html' templateUrl: './fire-page.html'
}) })
export class FirePageComponent implements OnDestroy, OnInit { export class FirePageComponent implements OnDestroy, OnInit {
public accountClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public deviceType: string; public deviceType: string;
public economicMarketClusterRiskRules: PortfolioReportRule[];
public emergencyFundRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[];
public fireWealth: Big; public fireWealth: Big;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public inactiveRules: PortfolioReportRule[];
public isLoading = false; public isLoading = false;
public isLoadingPortfolioReport = false;
public user: User; public user: User;
public withdrawalRatePerMonth: Big; public withdrawalRatePerMonth: Big;
public withdrawalRatePerYear: Big; public withdrawalRatePerYear: Big;
@ -95,8 +83,6 @@ export class FirePageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
this.initializePortfolioReport();
} }
public onAnnualInterestRateChange(annualInterestRate: number) { public onAnnualInterestRateChange(annualInterestRate: number) {
@ -133,21 +119,6 @@ export class FirePageComponent implements OnDestroy, OnInit {
}); });
}); });
} }
public onRulesUpdated(event: UpdateUserSettingDto) {
this.dataService
.putUserSetting(event)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.initializePortfolioReport();
});
}
public onSavingsRateChange(savingsRate: number) { public onSavingsRateChange(savingsRate: number) {
this.dataService this.dataService
.putUserSetting({ savingsRate }) .putUserSetting({ savingsRate })
@ -187,66 +158,4 @@ export class FirePageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private initializePortfolioReport() {
this.isLoadingPortfolioReport = true;
this.dataService
.fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioReport) => {
this.inactiveRules = this.mergeInactiveRules(portfolioReport);
this.accountClusterRiskRules =
portfolioReport.rules['accountClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.currencyClusterRiskRules =
portfolioReport.rules['currencyClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.economicMarketClusterRiskRules =
portfolioReport.rules['economicMarketClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.emergencyFundRules =
portfolioReport.rules['emergencyFund']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.feeRules =
portfolioReport.rules['fees']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.isLoadingPortfolioReport = false;
this.changeDetectorRef.markForCheck();
});
}
private mergeInactiveRules(report: PortfolioReport): PortfolioReportRule[] {
let inactiveRules: PortfolioReportRule[] = [];
for (const category in report.rules) {
const rulesArray = report.rules[category];
inactiveRules = inactiveRules.concat(
rulesArray.filter(({ isActive }) => {
return !isActive;
})
);
}
return inactiveRules;
}
} }

130
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -101,133 +101,3 @@
} }
</div> </div>
</div> </div>
<div class="container mt-5">
<div class="row">
<div class="col">
<h2 class="h3 mb-3 text-center">X-ray</h2>
<p class="mb-4">
<span i18n
>Ghostfolio X-ray uses static analysis to identify potential issues
and risks in your portfolio.</span
>
<span class="d-none"
>It will be highly configurable in the future: activate / deactivate
rules and customize the thresholds to match your personal investment
style.</span
>
</p>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Emergency Fund</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="emergencyFundRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Currency Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="currencyClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Account Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="accountClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Economic Market Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="economicMarketClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Fees</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="feeRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
@if (inactiveRules?.length > 0) {
<div>
<h4 class="m-0" i18n>Inactive</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="inactiveRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
}
</div>
</div>
</div>

2
apps/client/src/app/pages/portfolio/fire/fire-page.module.ts

@ -1,4 +1,3 @@
import { GfRulesModule } from '@ghostfolio/client/components/rules/rules.module';
import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator'; import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -17,7 +16,6 @@ import { FirePageComponent } from './fire-page.component';
FirePageRoutingModule, FirePageRoutingModule,
GfFireCalculatorComponent, GfFireCalculatorComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfRulesModule,
GfValueComponent, GfValueComponent,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],

5
apps/client/src/app/pages/portfolio/portfolio-page-routing.module.ts

@ -34,6 +34,11 @@ const routes: Routes = [
path: 'fire', path: 'fire',
loadChildren: () => loadChildren: () =>
import('./fire/fire-page.module').then((m) => m.FirePageModule) import('./fire/fire-page.module').then((m) => m.FirePageModule)
},
{
path: 'x-ray',
loadChildren: () =>
import('./x-ray/x-ray-page.module').then((m) => m.XRayPageModule)
} }
], ],
component: PortfolioPageComponent, component: PortfolioPageComponent,

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

@ -46,8 +46,13 @@ export class PortfolioPageComponent implements OnDestroy, OnInit {
}, },
{ {
iconName: 'calculator-outline', iconName: 'calculator-outline',
label: 'FIRE / X-ray', label: 'FIRE ',
path: ['/portfolio', 'fire'] path: ['/portfolio', 'fire']
},
{
iconName: 'scan-outline',
label: 'X-ray',
path: ['/portfolio', 'x-ray']
} }
]; ];
this.user = state.user; this.user = state.user;

21
apps/client/src/app/pages/portfolio/x-ray/x-ray-page-routing.module.ts

@ -0,0 +1,21 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { XRayPageComponent } from './x-ray-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: XRayPageComponent,
path: '',
title: 'X-ray'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class XRayPageRoutingModule {}

123
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html

@ -0,0 +1,123 @@
<div class="container">
<div class="row">
<div class="col">
<h2 class="d-none d-sm-block h3 mb-3 text-center">X-ray</h2>
<p class="mb-4" i18n>
Ghostfolio X-ray uses static analysis to uncover potential issues and
risks in your portfolio. Adjust the rules below and set custom
thresholds to align with your personal investment strategy.
</p>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Emergency Fund</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="emergencyFundRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Currency Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="currencyClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Account Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="accountClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Economic Market Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="economicMarketClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Fees</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="feeRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
@if (inactiveRules?.length > 0) {
<div>
<h4 class="m-0" i18n>Inactive</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="inactiveRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
}
</div>
</div>
</div>

3
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.scss

@ -0,0 +1,3 @@
:host {
display: block;
}

150
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts

@ -0,0 +1,150 @@
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
PortfolioReportRule,
PortfolioReport
} from '@ghostfolio/common/interfaces';
import { User } from '@ghostfolio/common/interfaces/user.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'gf-x-ray-page',
styleUrl: './x-ray-page.component.scss',
templateUrl: './x-ray-page.component.html'
})
export class XRayPageComponent {
public accountClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public economicMarketClusterRiskRules: PortfolioReportRule[];
public emergencyFundRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[];
public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public inactiveRules: PortfolioReportRule[];
public isLoadingPortfolioReport = false;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService
) {}
public ngOnInit() {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToUpdateUserSettings =
this.user.subscription?.type === 'Basic'
? false
: hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.changeDetectorRef.markForCheck();
}
});
this.initializePortfolioReport();
}
public onRulesUpdated(event: UpdateUserSettingDto) {
this.dataService
.putUserSetting(event)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.initializePortfolioReport();
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private initializePortfolioReport() {
this.isLoadingPortfolioReport = true;
this.dataService
.fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioReport) => {
this.inactiveRules = this.mergeInactiveRules(portfolioReport);
this.accountClusterRiskRules =
portfolioReport.rules['accountClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.currencyClusterRiskRules =
portfolioReport.rules['currencyClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.economicMarketClusterRiskRules =
portfolioReport.rules['economicMarketClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.emergencyFundRules =
portfolioReport.rules['emergencyFund']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.feeRules =
portfolioReport.rules['fees']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.isLoadingPortfolioReport = false;
this.changeDetectorRef.markForCheck();
});
}
private mergeInactiveRules(report: PortfolioReport): PortfolioReportRule[] {
let inactiveRules: PortfolioReportRule[] = [];
for (const category in report.rules) {
const rulesArray = report.rules[category];
inactiveRules = inactiveRules.concat(
rulesArray.filter(({ isActive }) => {
return !isActive;
})
);
}
return inactiveRules;
}
}

22
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.module.ts

@ -0,0 +1,22 @@
import { GfRulesModule } from '@ghostfolio/client/components/rules/rules.module';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { XRayPageRoutingModule } from './x-ray-page-routing.module';
import { XRayPageComponent } from './x-ray-page.component';
@NgModule({
declarations: [XRayPageComponent],
imports: [
CommonModule,
GfPremiumIndicatorComponent,
GfRulesModule,
NgxSkeletonLoaderModule,
XRayPageRoutingModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class XRayPageModule {}

24
apps/client/src/app/pages/pricing/pricing-page.component.ts

@ -6,6 +6,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { StringValue } from 'ms';
import { StripeService } from 'ngx-stripe'; import { StripeService } from 'ngx-stripe';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators'; import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@ -20,6 +21,7 @@ export class PricingPageComponent implements OnDestroy, OnInit {
public baseCurrency: string; public baseCurrency: string;
public coupon: number; public coupon: number;
public couponId: string; public couponId: string;
public durationExtension: StringValue;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public importAndExportTooltipBasic = translate( public importAndExportTooltipBasic = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC' 'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC'
@ -51,11 +53,12 @@ export class PricingPageComponent implements OnDestroy, OnInit {
) {} ) {}
public ngOnInit() { public ngOnInit() {
const { baseCurrency, subscriptions } = this.dataService.fetchInfo(); const { baseCurrency, subscriptionOffers } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency; this.baseCurrency = baseCurrency;
this.coupon = subscriptions?.default?.coupon; this.coupon = subscriptionOffers?.default?.coupon;
this.price = subscriptions?.default?.price; this.durationExtension = subscriptionOffers?.default?.durationExtension;
this.price = subscriptionOffers?.default?.price;
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -68,11 +71,18 @@ export class PricingPageComponent implements OnDestroy, OnInit {
permissions.updateUserSettings permissions.updateUserSettings
); );
this.coupon = subscriptions?.[this.user?.subscription?.offer]?.coupon; this.coupon =
subscriptionOffers?.[this.user?.subscription?.offer]?.coupon;
this.couponId = this.couponId =
subscriptions?.[this.user.subscription.offer]?.couponId; subscriptionOffers?.[this.user.subscription.offer]?.couponId;
this.price = subscriptions?.[this.user?.subscription?.offer]?.price; this.durationExtension =
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId; subscriptionOffers?.[
this.user?.subscription?.offer
]?.durationExtension;
this.price =
subscriptionOffers?.[this.user?.subscription?.offer]?.price;
this.priceId =
subscriptionOffers?.[this.user.subscription.offer]?.priceId;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }

18
apps/client/src/app/pages/pricing/pricing-page.html

@ -101,6 +101,11 @@
</p> </p>
</div> </div>
} }
@if (durationExtension) {
<div class="d-none d-lg-block hidden mt-3">
<div class="badge p-3 w-100">&nbsp;</div>
</div>
}
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
@ -159,6 +164,11 @@
</p> </p>
</div> </div>
} }
@if (durationExtension) {
<div class="d-none d-lg-block hidden mt-3">
<div class="badge p-3 w-100">&nbsp;</div>
</div>
}
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
@ -289,6 +299,14 @@
</p> </p>
</div> </div>
} }
@if (durationExtension) {
<div class="mt-3">
<div class="badge badge-warning font-weight-normal p-3 w-100">
<strong>Limited Offer!</strong> Get
{{ durationExtension }} extra
</div>
</div>
}
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>

4
apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts

@ -35,9 +35,9 @@ export class GfProductPageComponent implements OnInit {
) {} ) {}
public ngOnInit() { public ngOnInit() {
const { subscriptions } = this.dataService.fetchInfo(); const { subscriptionOffers } = this.dataService.fetchInfo();
this.price = subscriptions?.default?.price; this.price = subscriptionOffers?.default?.price;
this.product1 = { this.product1 = {
founded: 2021, founded: 2021,

10
apps/client/src/app/services/data.service.ts

@ -10,7 +10,6 @@ import {
} from '@ghostfolio/api/app/order/interfaces/activities.interface'; } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioHoldingDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-holding-detail.interface'; import { PortfolioHoldingDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-holding-detail.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface'; import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
import { DeleteOwnUserDto } from '@ghostfolio/api/app/user/delete-own-user.dto'; import { DeleteOwnUserDto } from '@ghostfolio/api/app/user/delete-own-user.dto';
import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface'; import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface';
@ -30,6 +29,7 @@ import {
Filter, Filter,
ImportResponse, ImportResponse,
InfoItem, InfoItem,
LookupResponse,
OAuthResponse, OAuthResponse,
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
@ -464,10 +464,10 @@ export class DataService {
} }
return this.http return this.http
.get<{ items: LookupItem[] }>('/api/v1/symbol/lookup', { params }) .get<LookupResponse>('/api/v1/symbol/lookup', { params })
.pipe( .pipe(
map((respose) => { map(({ items }) => {
return respose.items; return items;
}) })
); );
} }
@ -535,7 +535,7 @@ export class DataService {
}: { }: {
filters?: Filter[]; filters?: Filter[];
range?: DateRange; range?: DateRange;
}) { } = {}) {
let params = this.buildFiltersAsQueryParams({ filters }); let params = this.buildFiltersAsQueryParams({ filters });
if (range) { if (range) {

14
apps/client/src/app/services/user/user.service.ts

@ -65,6 +65,20 @@ export class UserService extends ObservableStore<UserStoreState> {
}); });
} }
if (user?.settings['filters.dataSource']) {
filters.push({
id: user.settings['filters.dataSource'],
type: 'DATA_SOURCE'
});
}
if (user?.settings['filters.symbol']) {
filters.push({
id: user.settings['filters.symbol'],
type: 'SYMBOL'
});
}
if (user?.settings['filters.tags']) { if (user?.settings['filters.tags']) {
filters.push({ filters.push({
id: user.settings['filters.tags'].join(','), id: user.settings['filters.tags'].join(','),

108
apps/client/src/locales/messages.it.xlf

@ -2872,7 +2872,7 @@
</trans-unit> </trans-unit>
<trans-unit id="82fe55446d3fad9db11eb79caaedf325587b9c0a" datatype="html"> <trans-unit id="82fe55446d3fad9db11eb79caaedf325587b9c0a" datatype="html">
<source> Hello, <x id="INTERPOLATION" equiv-text="{{ publicPortfolioDetails?.alias ?? &apos;someone&apos; }}"/> has shared a <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Portfolio<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/> with you! </source> <source> Hello, <x id="INTERPOLATION" equiv-text="{{ publicPortfolioDetails?.alias ?? &apos;someone&apos; }}"/> has shared a <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Portfolio<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/> with you! </source>
<target state="new">Salve, <x id="INTERPOLATION" equiv-text="{{ portfolioPublicDetails?.alias ?? &apos;someone&apos; }}"/> ha condiviso un <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Portafoglio<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/> con te! </target> <target state="translated">Salve, <x id="INTERPOLATION" equiv-text="{{ portfolioPublicDetails?.alias ?? &apos;someone&apos; }}"/> ha condiviso un <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Portafoglio<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/> con te! </target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context>
<context context-type="linenumber">4</context> <context context-type="linenumber">4</context>
@ -5881,7 +5881,7 @@
</trans-unit> </trans-unit>
<trans-unit id="50760c2140335b2324deaee120f40d9aa64b3238" datatype="html"> <trans-unit id="50760c2140335b2324deaee120f40d9aa64b3238" datatype="html">
<source>Currency Cluster Risks</source> <source>Currency Cluster Risks</source>
<target state="new">Currency Cluster Risks</target> <target state="translated">Rischio di Concentrazione Valutario</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context>
<context context-type="linenumber">141</context> <context context-type="linenumber">141</context>
@ -5889,7 +5889,7 @@
</trans-unit> </trans-unit>
<trans-unit id="c7b797e5df289241021db16010efb6ac3c6cfb86" datatype="html"> <trans-unit id="c7b797e5df289241021db16010efb6ac3c6cfb86" datatype="html">
<source>Account Cluster Risks</source> <source>Account Cluster Risks</source>
<target state="new">Account Cluster Risks</target> <target state="translated">Rischi di Concentrazione dei Conti</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context>
<context context-type="linenumber">160</context> <context context-type="linenumber">160</context>
@ -6237,7 +6237,7 @@
</trans-unit> </trans-unit>
<trans-unit id="97bad3b5e318e5c7c755cd99062f2973efcf17e5" datatype="html"> <trans-unit id="97bad3b5e318e5c7c755cd99062f2973efcf17e5" datatype="html">
<source>Restricted view</source> <source>Restricted view</source>
<target state="new">Restricted view</target> <target state="translated">Vista limitata</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/access-table/access-table.component.html</context> <context context-type="sourcefile">apps/client/src/app/components/access-table/access-table.component.html</context>
<context context-type="linenumber">26</context> <context context-type="linenumber">26</context>
@ -6357,7 +6357,7 @@
</trans-unit> </trans-unit>
<trans-unit id="7451343426685730864" datatype="html"> <trans-unit id="7451343426685730864" datatype="html">
<source>WTD</source> <source>WTD</source>
<target state="new">WTD</target> <target state="translated">Settimana corrente</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/assistant/assistant.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/assistant/assistant.component.ts</context>
<context context-type="linenumber">212</context> <context context-type="linenumber">212</context>
@ -6373,7 +6373,7 @@
</trans-unit> </trans-unit>
<trans-unit id="399380803601269035" datatype="html"> <trans-unit id="399380803601269035" datatype="html">
<source>MTD</source> <source>MTD</source>
<target state="new">MTD</target> <target state="translated">Mese corrente</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/assistant/assistant.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/assistant/assistant.component.ts</context>
<context context-type="linenumber">216</context> <context context-type="linenumber">216</context>
@ -6597,7 +6597,7 @@
</trans-unit> </trans-unit>
<trans-unit id="ebf471e68247ca2110cdc5c98538e2e2bbf6e56e" datatype="html"> <trans-unit id="ebf471e68247ca2110cdc5c98538e2e2bbf6e56e" datatype="html">
<source>{VAR_PLURAL, plural, =1 {activity} other {activities}}</source> <source>{VAR_PLURAL, plural, =1 {activity} other {activities}}</source>
<target state="new">{VAR_PLURAL, plural, =1 {activity} other {activities}}</target> <target state="translated">{VAR_PLURAL, plural, =1 {attivit&agrave;} other {attivit&agrave;}}</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html</context> <context context-type="sourcefile">apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html</context>
<context context-type="linenumber">14</context> <context context-type="linenumber">14</context>
@ -6781,7 +6781,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4622218074144052433" datatype="html"> <trans-unit id="4622218074144052433" datatype="html">
<source>Family Office</source> <source>Family Office</source>
<target state="new">Family Office</target> <target state="translated">Ufficio familiare</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts</context>
<context context-type="linenumber">87</context> <context context-type="linenumber">87</context>
@ -6837,7 +6837,7 @@
</trans-unit> </trans-unit>
<trans-unit id="2657610384052021428" datatype="html"> <trans-unit id="2657610384052021428" datatype="html">
<source>User Experience</source> <source>User Experience</source>
<target state="new">User Experience</target> <target state="translated">Esperienza Utente</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts</context>
<context context-type="linenumber">98</context> <context context-type="linenumber">98</context>
@ -7061,7 +7061,7 @@
</trans-unit> </trans-unit>
<trans-unit id="665692df9ab12bc228c1276f7d04e97902ff9afc" datatype="html"> <trans-unit id="665692df9ab12bc228c1276f7d04e97902ff9afc" datatype="html">
<source>Copy link to clipboard</source> <source>Copy link to clipboard</source>
<target state="new">Copy link to clipboard</target> <target state="translated">Copia link negli appunti</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/access-table/access-table.component.html</context> <context context-type="sourcefile">apps/client/src/app/components/access-table/access-table.component.html</context>
<context context-type="linenumber">70</context> <context context-type="linenumber">70</context>
@ -7069,7 +7069,7 @@
</trans-unit> </trans-unit>
<trans-unit id="306e3758e5303c780f0984c003e6283d49796f79" datatype="html"> <trans-unit id="306e3758e5303c780f0984c003e6283d49796f79" datatype="html">
<source>Portfolio Snapshot</source> <source>Portfolio Snapshot</source>
<target state="new">Portfolio Snapshot</target> <target state="translated">Stato del Portfolio</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-jobs/admin-jobs.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-jobs/admin-jobs.html</context>
<context context-type="linenumber">39</context> <context context-type="linenumber">39</context>
@ -7077,7 +7077,7 @@
</trans-unit> </trans-unit>
<trans-unit id="76897e07c5670ce3b7710cc10c5e1c08b5f6a83a" datatype="html"> <trans-unit id="76897e07c5670ce3b7710cc10c5e1c08b5f6a83a" datatype="html">
<source><x id="START_BLOCK_IF" equiv-text="Currency !== SymbolProfile?.currency ) {"/> Change with currency effect <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Change <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></source> <source><x id="START_BLOCK_IF" equiv-text="Currency !== SymbolProfile?.currency ) {"/> Change with currency effect <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Change <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></source>
<target state="new"><x id="START_BLOCK_IF" equiv-text="Currency !== SymbolProfile?.currency ) {"/> Change with currency effect <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Change <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></target> <target state="translated"><x id="START_BLOCK_IF" equiv-text="Currency !== SymbolProfile?.currency ) {"/> Cambio con effetto valuta <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Cambia <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html</context>
<context context-type="linenumber">50</context> <context context-type="linenumber">50</context>
@ -7085,7 +7085,7 @@
</trans-unit> </trans-unit>
<trans-unit id="65ff514a2e167229e1a34b3712f2cf2908576d0f" datatype="html"> <trans-unit id="65ff514a2e167229e1a34b3712f2cf2908576d0f" datatype="html">
<source><x id="START_BLOCK_IF" equiv-text="Currency !== SymbolProfile?.currency ) {"/> Performance with currency effect <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Performance <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></source> <source><x id="START_BLOCK_IF" equiv-text="Currency !== SymbolProfile?.currency ) {"/> Performance with currency effect <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Performance <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></source>
<target state="new"><x id="START_BLOCK_IF" equiv-text="Currency !== SymbolProfile?.currency ) {"/> Performance with currency effect <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Performance <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></target> <target state="translated"><x id="START_BLOCK_IF" equiv-text="Currency !== SymbolProfile?.currency ) {"/> Prestazioni con effetto valuta <x id="CLOSE_BLOCK_IF" equiv-text="}"/><x id="START_BLOCK_ELSE" equiv-text="@else {"/> Prestazioni <x id="CLOSE_BLOCK_ELSE" equiv-text="}"/></target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html</context>
<context context-type="linenumber">69</context> <context context-type="linenumber">69</context>
@ -7093,7 +7093,7 @@
</trans-unit> </trans-unit>
<trans-unit id="5502bf2eace842803c7b3f5ce5f600e102d3424a" datatype="html"> <trans-unit id="5502bf2eace842803c7b3f5ce5f600e102d3424a" datatype="html">
<source>Threshold Min</source> <source>Threshold Min</source>
<target state="new">Threshold Min</target> <target state="translated">Soglia Minima</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html</context>
<context context-type="linenumber">9</context> <context context-type="linenumber">9</context>
@ -7101,7 +7101,7 @@
</trans-unit> </trans-unit>
<trans-unit id="012b48ee5281a77c66760b2007c3ccd7e34aa340" datatype="html"> <trans-unit id="012b48ee5281a77c66760b2007c3ccd7e34aa340" datatype="html">
<source>Threshold Max</source> <source>Threshold Max</source>
<target state="new">Threshold Max</target> <target state="translated">Soglia Massima</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html</context>
<context context-type="linenumber">44</context> <context context-type="linenumber">44</context>
@ -7109,7 +7109,7 @@
</trans-unit> </trans-unit>
<trans-unit id="f4e529ae5ffd73001d1ff4bbdeeb0a72e342e5c8" datatype="html"> <trans-unit id="f4e529ae5ffd73001d1ff4bbdeeb0a72e342e5c8" datatype="html">
<source>Close</source> <source>Close</source>
<target state="new">Close</target> <target state="translated">Chiudi</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html</context>
<context context-type="linenumber">77</context> <context context-type="linenumber">77</context>
@ -7117,7 +7117,7 @@
</trans-unit> </trans-unit>
<trans-unit id="072d4d4ec83a5a97345a1c13b90c213b47326d09" datatype="html"> <trans-unit id="072d4d4ec83a5a97345a1c13b90c213b47326d09" datatype="html">
<source>Customize</source> <source>Customize</source>
<target state="new">Customize</target> <target state="translated">Personalizza</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/rule/rule.component.html</context> <context context-type="sourcefile">apps/client/src/app/components/rule/rule.component.html</context>
<context context-type="linenumber">67</context> <context context-type="linenumber">67</context>
@ -7125,7 +7125,7 @@
</trans-unit> </trans-unit>
<trans-unit id="06296af0cdaf7bed02043379359ed1975fc22077" datatype="html"> <trans-unit id="06296af0cdaf7bed02043379359ed1975fc22077" datatype="html">
<source>No auto-renewal.</source> <source>No auto-renewal.</source>
<target state="new">No auto-renewal.</target> <target state="translated">No rinnovo automatico.</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-account-membership/user-account-membership.html</context> <context context-type="sourcefile">apps/client/src/app/components/user-account-membership/user-account-membership.html</context>
<context context-type="linenumber">62</context> <context context-type="linenumber">62</context>
@ -7133,7 +7133,7 @@
</trans-unit> </trans-unit>
<trans-unit id="7fb1099e29660162f9154d5b2feee7743a423df6" datatype="html"> <trans-unit id="7fb1099e29660162f9154d5b2feee7743a423df6" datatype="html">
<source>Today</source> <source>Today</source>
<target state="new">Today</target> <target state="translated">Oggi</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context>
<context context-type="linenumber">24</context> <context context-type="linenumber">24</context>
@ -7141,7 +7141,7 @@
</trans-unit> </trans-unit>
<trans-unit id="65cefcc53d1f6445df7568e8a40c49165f1090ee" datatype="html"> <trans-unit id="65cefcc53d1f6445df7568e8a40c49165f1090ee" datatype="html">
<source>This year</source> <source>This year</source>
<target state="new">This year</target> <target state="translated">Anno corrente</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context>
<context context-type="linenumber">42</context> <context context-type="linenumber">42</context>
@ -7149,7 +7149,7 @@
</trans-unit> </trans-unit>
<trans-unit id="5beadaafe995fa04343008b0ab57e579c9fc81b9" datatype="html"> <trans-unit id="5beadaafe995fa04343008b0ab57e579c9fc81b9" datatype="html">
<source>From the beginning</source> <source>From the beginning</source>
<target state="new">From the beginning</target> <target state="translated">Dall'inizio</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context>
<context context-type="linenumber">60</context> <context context-type="linenumber">60</context>
@ -7157,7 +7157,7 @@
</trans-unit> </trans-unit>
<trans-unit id="f7ed8f2e1ac78c5a63741ae684779bcf7a99ab16" datatype="html"> <trans-unit id="f7ed8f2e1ac78c5a63741ae684779bcf7a99ab16" datatype="html">
<source>Oops! Invalid currency.</source> <source>Oops! Invalid currency.</source>
<target state="new">Oops! Invalid currency.</target> <target state="translated">Oops! Valuta sbagliata.</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.html</context>
<context context-type="linenumber">49</context> <context context-type="linenumber">49</context>
@ -7165,7 +7165,7 @@
</trans-unit> </trans-unit>
<trans-unit id="f1cc4a59110dd72517210565f76118df74336fec" datatype="html"> <trans-unit id="f1cc4a59110dd72517210565f76118df74336fec" datatype="html">
<source>This page has been archived.</source> <source>This page has been archived.</source>
<target state="new">This page has been archived.</target> <target state="translated">Questa pagina è stata archiviata.</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">14</context> <context context-type="linenumber">14</context>
@ -7173,7 +7173,7 @@
</trans-unit> </trans-unit>
<trans-unit id="ea66ce0bb1cb1dcee2bcb86a8876c5f2c2fa65f6" datatype="html"> <trans-unit id="ea66ce0bb1cb1dcee2bcb86a8876c5f2c2fa65f6" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> is Open Source Software</source> <source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> is Open Source Software</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> is Open Source Software</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> è un programma Open Source</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">139</context> <context context-type="linenumber">139</context>
@ -7181,7 +7181,7 @@
</trans-unit> </trans-unit>
<trans-unit id="aa945d3772281b66851cc8b86d6de9726e65db70" datatype="html"> <trans-unit id="aa945d3772281b66851cc8b86d6de9726e65db70" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> is not Open Source Software</source> <source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> is not Open Source Software</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> is not Open Source Software</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> non è un programma Open Source</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">146</context> <context context-type="linenumber">146</context>
@ -7189,7 +7189,7 @@
</trans-unit> </trans-unit>
<trans-unit id="942e46b94b9d9662c3c3b7b5e7f9005c7b9feab7" datatype="html"> <trans-unit id="942e46b94b9d9662c3c3b7b5e7f9005c7b9feab7" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> is Open Source Software</source> <source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> is Open Source Software</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> is Open Source Software</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> è un programma Open Source</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">156</context> <context context-type="linenumber">156</context>
@ -7197,7 +7197,7 @@
</trans-unit> </trans-unit>
<trans-unit id="08beed5100360c242c0aec92e8706d92f0cb70f3" datatype="html"> <trans-unit id="08beed5100360c242c0aec92e8706d92f0cb70f3" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> is not Open Source Software</source> <source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> is not Open Source Software</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> is not Open Source Software</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> non è un programma Open Source</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">163</context> <context context-type="linenumber">163</context>
@ -7205,7 +7205,7 @@
</trans-unit> </trans-unit>
<trans-unit id="1302d86668c92816f6e69a61fee77d573ace918b" datatype="html"> <trans-unit id="1302d86668c92816f6e69a61fee77d573ace918b" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> can be self-hosted</source> <source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> can be self-hosted</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> can be self-hosted</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> pu&ograve; essere ospitato in proprio </target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">178</context> <context context-type="linenumber">178</context>
@ -7213,7 +7213,7 @@
</trans-unit> </trans-unit>
<trans-unit id="2371f735292afd7ed2b6c90640068d33667933e7" datatype="html"> <trans-unit id="2371f735292afd7ed2b6c90640068d33667933e7" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> cannot be self-hosted</source> <source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> cannot be self-hosted</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> cannot be self-hosted</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> non pu&ograve; essere ospitato in proprio</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">185</context> <context context-type="linenumber">185</context>
@ -7221,7 +7221,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4803807735afa465179312a0094a432c7d8e1a55" datatype="html"> <trans-unit id="4803807735afa465179312a0094a432c7d8e1a55" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> can be self-hosted</source> <source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> can be self-hosted</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> can be self-hosted</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> pu&ograve; essere ospitato in proprio</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">195</context> <context context-type="linenumber">195</context>
@ -7229,7 +7229,7 @@
</trans-unit> </trans-unit>
<trans-unit id="e44d1606f6b1c8b549b5bbc22c8f2d53b6626404" datatype="html"> <trans-unit id="e44d1606f6b1c8b549b5bbc22c8f2d53b6626404" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> cannot be self-hosted</source> <source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> cannot be self-hosted</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> cannot be self-hosted</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> non pu&ograve; essere ospitato in proprio</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">202</context> <context context-type="linenumber">202</context>
@ -7237,7 +7237,7 @@
</trans-unit> </trans-unit>
<trans-unit id="575dc02bb1fbd579cb7ecca00198bbf2893d6115" datatype="html"> <trans-unit id="575dc02bb1fbd579cb7ecca00198bbf2893d6115" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> can be used anonymously</source> <source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> can be used anonymously</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> can be used anonymously</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> pu&ograve; essere usato anonimamente</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">217</context> <context context-type="linenumber">217</context>
@ -7245,7 +7245,7 @@
</trans-unit> </trans-unit>
<trans-unit id="60bcadcc133112179baf414d6bca496616026ddf" datatype="html"> <trans-unit id="60bcadcc133112179baf414d6bca496616026ddf" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> cannot be used anonymously</source> <source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> cannot be used anonymously</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> cannot be used anonymously</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> non pu&ograve; essere usato anonimamente</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">224</context> <context context-type="linenumber">224</context>
@ -7253,7 +7253,7 @@
</trans-unit> </trans-unit>
<trans-unit id="ea9dead7214d6e5e68bedcfc41ed92e6f331b476" datatype="html"> <trans-unit id="ea9dead7214d6e5e68bedcfc41ed92e6f331b476" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> can be used anonymously</source> <source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> can be used anonymously</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> can be used anonymously</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> pu&ograve; essere usato anonimamente</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">234</context> <context context-type="linenumber">234</context>
@ -7261,7 +7261,7 @@
</trans-unit> </trans-unit>
<trans-unit id="d69adb6f3253a16368a171d0a5d998e9a6b95717" datatype="html"> <trans-unit id="d69adb6f3253a16368a171d0a5d998e9a6b95717" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> cannot be used anonymously</source> <source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> cannot be used anonymously</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> cannot be used anonymously</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> non pu&ograve; essere usato anonimamente</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">241</context> <context context-type="linenumber">241</context>
@ -7269,7 +7269,7 @@
</trans-unit> </trans-unit>
<trans-unit id="0499b37a6ed7d654307685844b35684df638a95f" datatype="html"> <trans-unit id="0499b37a6ed7d654307685844b35684df638a95f" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> offers a free plan</source> <source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> offers a free plan</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> offers a free plan</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> ha un piano gratuito</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">256</context> <context context-type="linenumber">256</context>
@ -7277,7 +7277,7 @@
</trans-unit> </trans-unit>
<trans-unit id="0a3bfda56ea7cd7ae419cb5091e3a68ade032c30" datatype="html"> <trans-unit id="0a3bfda56ea7cd7ae419cb5091e3a68ade032c30" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> does not offer a free plan</source> <source><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> does not offer a free plan</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> does not offer a free plan</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product1.name }}"/> non ha un piano gratuito</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">263</context> <context context-type="linenumber">263</context>
@ -7285,7 +7285,7 @@
</trans-unit> </trans-unit>
<trans-unit id="04eede9cafd81f04b01b6c7937e047824f78b05d" datatype="html"> <trans-unit id="04eede9cafd81f04b01b6c7937e047824f78b05d" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> offers a free plan</source> <source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> offers a free plan</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> offers a free plan</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> ha un piano gratuito</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">273</context> <context context-type="linenumber">273</context>
@ -7293,7 +7293,7 @@
</trans-unit> </trans-unit>
<trans-unit id="2452ec985c64ade4904d93697e68d3846beb2bf4" datatype="html"> <trans-unit id="2452ec985c64ade4904d93697e68d3846beb2bf4" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> does not offer a free plan</source> <source><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> does not offer a free plan</source>
<target state="new"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> does not offer a free plan</target> <target state="translated"><x id="INTERPOLATION" equiv-text="{{ product2.name }}"/> non ha un piano gratuito</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">280</context> <context context-type="linenumber">280</context>
@ -7301,7 +7301,7 @@
</trans-unit> </trans-unit>
<trans-unit id="b225488f8b209e9704760dc9f5d99845a5d07bf6" datatype="html"> <trans-unit id="b225488f8b209e9704760dc9f5d99845a5d07bf6" datatype="html">
<source>Oops! Could not find any assets.</source> <source>Oops! Could not find any assets.</source>
<target state="new">Oops! Could not find any assets.</target> <target state="translated">Oops! Non ho trovato alcun asset.</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html</context>
<context context-type="linenumber">37</context> <context context-type="linenumber">37</context>
@ -7309,7 +7309,7 @@
</trans-unit> </trans-unit>
<trans-unit id="be839b9dc1563aec0f80f5b55c8bde1a1dd10ca1" datatype="html"> <trans-unit id="be839b9dc1563aec0f80f5b55c8bde1a1dd10ca1" datatype="html">
<source>Data Providers</source> <source>Data Providers</source>
<target state="new">Data Providers</target> <target state="translated">Fornitori di dati</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context>
<context context-type="linenumber">4</context> <context context-type="linenumber">4</context>
@ -7317,7 +7317,7 @@
</trans-unit> </trans-unit>
<trans-unit id="b082171edef6d502186c4577e201b2ef7935e955" datatype="html"> <trans-unit id="b082171edef6d502186c4577e201b2ef7935e955" datatype="html">
<source>NEW</source> <source>NEW</source>
<target state="new">NEW</target> <target state="translated">NUOVO</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context>
<context context-type="linenumber">14</context> <context context-type="linenumber">14</context>
@ -7325,7 +7325,7 @@
</trans-unit> </trans-unit>
<trans-unit id="66cbb0c1695dc25d22f824b62f0ffb9435ec8c4b" datatype="html"> <trans-unit id="66cbb0c1695dc25d22f824b62f0ffb9435ec8c4b" datatype="html">
<source>Set API Key</source> <source>Set API Key</source>
<target state="new">Set API Key</target> <target state="translated">Imposta API Key</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context>
<context context-type="linenumber">29</context> <context context-type="linenumber">29</context>
@ -7333,7 +7333,7 @@
</trans-unit> </trans-unit>
<trans-unit id="2fcf96765ae87821e12fe4f6900ba1a218742cfc" datatype="html"> <trans-unit id="2fcf96765ae87821e12fe4f6900ba1a218742cfc" datatype="html">
<source> Want to stay updated? Click below to get notified as soon as it’s available. </source> <source> Want to stay updated? Click below to get notified as soon as it’s available. </source>
<target state="new"> Want to stay updated? Click below to get notified as soon as it’s available. </target> <target state="translated"> Vuoi seguire le novità? Clicca sotto per essere notificato appena è disponibile. </target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html</context>
<context context-type="linenumber">23</context> <context context-type="linenumber">23</context>
@ -7341,7 +7341,7 @@
</trans-unit> </trans-unit>
<trans-unit id="3a0843b9fa08ab1d2294e2b5bd60042052ab8d10" datatype="html"> <trans-unit id="3a0843b9fa08ab1d2294e2b5bd60042052ab8d10" datatype="html">
<source> Notify me </source> <source> Notify me </source>
<target state="new"> Notify me </target> <target state="translated"> Notificami </target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html</context>
<context context-type="linenumber">32</context> <context context-type="linenumber">32</context>
@ -7349,7 +7349,7 @@
</trans-unit> </trans-unit>
<trans-unit id="8908015589937012101" datatype="html"> <trans-unit id="8908015589937012101" datatype="html">
<source>Get access to 100’000+ tickers from over 50 exchanges</source> <source>Get access to 100’000+ tickers from over 50 exchanges</source>
<target state="new">Get access to 100’000+ tickers from over 50 exchanges</target> <target state="translated">Ottieni accesso a oltre 100’000+ titoli da oltre 50 borse</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">24</context> <context context-type="linenumber">24</context>
@ -7357,7 +7357,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4346283537747431562" datatype="html"> <trans-unit id="4346283537747431562" datatype="html">
<source>Ukraine</source> <source>Ukraine</source>
<target state="new">Ukraine</target> <target state="translated">Ucraina</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">92</context> <context context-type="linenumber">92</context>
@ -7365,7 +7365,7 @@
</trans-unit> </trans-unit>
<trans-unit id="f977904fb6da18ed1fcc2b56caa2315e991fd2ac" datatype="html"> <trans-unit id="f977904fb6da18ed1fcc2b56caa2315e991fd2ac" datatype="html">
<source> Skip </source> <source> Skip </source>
<target state="new"> Skip </target> <target state="translated"> Salta </target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html</context>
<context context-type="linenumber">83</context> <context context-type="linenumber">83</context>
@ -7373,7 +7373,7 @@
</trans-unit> </trans-unit>
<trans-unit id="3e0b7db80b1d6c100266b97b9bb3f9ddd7652844" datatype="html"> <trans-unit id="3e0b7db80b1d6c100266b97b9bb3f9ddd7652844" datatype="html">
<source>Join now</source> <source>Join now</source>
<target state="new">Join now</target> <target state="translated">Iscriviti adesso</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html</context>
<context context-type="linenumber">93</context> <context context-type="linenumber">93</context>
@ -7381,7 +7381,7 @@
</trans-unit> </trans-unit>
<trans-unit id="e8a4d8ee23e2c3f89acf7214598bcc3182c79268" datatype="html"> <trans-unit id="e8a4d8ee23e2c3f89acf7214598bcc3182c79268" datatype="html">
<source>Allocation Cluster Risks</source> <source>Allocation Cluster Risks</source>
<target state="new">Allocation Cluster Risks</target> <target state="translated">Rischi di allocazione dei Conti</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context>
<context context-type="linenumber">179</context> <context context-type="linenumber">179</context>
@ -7389,7 +7389,7 @@
</trans-unit> </trans-unit>
<trans-unit id="5020357869062357338" datatype="html"> <trans-unit id="5020357869062357338" datatype="html">
<source>Glossary</source> <source>Glossary</source>
<target state="new">Glossary</target> <target state="translated">Glossario</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/glossary/resources-glossary-routing.module.ts</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/glossary/resources-glossary-routing.module.ts</context>
<context context-type="linenumber">10</context> <context context-type="linenumber">10</context>
@ -7401,7 +7401,7 @@
</trans-unit> </trans-unit>
<trans-unit id="7423212324650924366" datatype="html"> <trans-unit id="7423212324650924366" datatype="html">
<source>Guides</source> <source>Guides</source>
<target state="new">Guides</target> <target state="translated">Guide</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/guides/resources-guides-routing.module.ts</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/guides/resources-guides-routing.module.ts</context>
<context context-type="linenumber">10</context> <context context-type="linenumber">10</context>
@ -7413,7 +7413,7 @@
</trans-unit> </trans-unit>
<trans-unit id="7491998780064454778" datatype="html"> <trans-unit id="7491998780064454778" datatype="html">
<source>guides</source> <source>guides</source>
<target state="new">guides</target> <target state="translated">guide</target>
<note priority="1" from="description">snake-case</note> <note priority="1" from="description">snake-case</note>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/overview/resources-overview.component.ts</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/overview/resources-overview.component.ts</context>
@ -7426,7 +7426,7 @@
</trans-unit> </trans-unit>
<trans-unit id="6255655462254999912" datatype="html"> <trans-unit id="6255655462254999912" datatype="html">
<source>glossary</source> <source>glossary</source>
<target state="new">glossary</target> <target state="translated">glossario</target>
<note priority="1" from="description">snake-case</note> <note priority="1" from="description">snake-case</note>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/overview/resources-overview.component.ts</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/overview/resources-overview.component.ts</context>

4
docker/docker-compose.build.yml

@ -1,6 +1,8 @@
name: ghostfolio_build
services: services:
ghostfolio: ghostfolio:
build: ../ build: ../
container_name: ghostfolio-build
init: true init: true
env_file: env_file:
- ../.env - ../.env
@ -23,6 +25,7 @@ services:
postgres: postgres:
image: docker.io/library/postgres:15 image: docker.io/library/postgres:15
container_name: gf-postgres-build
env_file: env_file:
- ../.env - ../.env
healthcheck: healthcheck:
@ -35,6 +38,7 @@ services:
redis: redis:
image: docker.io/library/redis:alpine image: docker.io/library/redis:alpine
container_name: gf-redis-build
env_file: env_file:
- ../.env - ../.env
command: ['redis-server', '--requirepass', $REDIS_PASSWORD] command: ['redis-server', '--requirepass', $REDIS_PASSWORD]

5
docker/docker-compose.dev.yml

@ -1,7 +1,8 @@
name: ghostfolio_dev
services: services:
postgres: postgres:
image: docker.io/library/postgres:15 image: docker.io/library/postgres:15
container_name: postgres container_name: gf-postgres-dev
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- ../.env - ../.env
@ -12,7 +13,7 @@ services:
redis: redis:
image: docker.io/library/redis:alpine image: docker.io/library/redis:alpine
container_name: redis container_name: gf-redis-dev
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- ../.env - ../.env

4
docker/docker-compose.yml

@ -1,6 +1,8 @@
name: ghostfolio
services: services:
ghostfolio: ghostfolio:
image: docker.io/ghostfolio/ghostfolio:latest image: docker.io/ghostfolio/ghostfolio:latest
container_name: ghostfolio
init: true init: true
cap_drop: cap_drop:
- ALL - ALL
@ -27,6 +29,7 @@ services:
postgres: postgres:
image: docker.io/library/postgres:15 image: docker.io/library/postgres:15
container_name: gf-postgres
cap_drop: cap_drop:
- ALL - ALL
cap_add: cap_add:
@ -49,6 +52,7 @@ services:
redis: redis:
image: docker.io/library/redis:alpine image: docker.io/library/redis:alpine
container_name: gf-redis
user: '999:1000' user: '999:1000'
cap_drop: cap_drop:
- ALL - ALL

8
libs/common/src/lib/interfaces/index.ts

@ -23,6 +23,7 @@ import type { Holding } from './holding.interface';
import type { InfoItem } from './info-item.interface'; import type { InfoItem } from './info-item.interface';
import type { InvestmentItem } from './investment-item.interface'; import type { InvestmentItem } from './investment-item.interface';
import type { LineChartItem } from './line-chart-item.interface'; import type { LineChartItem } from './line-chart-item.interface';
import type { LookupItem } from './lookup-item.interface';
import type { PortfolioChart } from './portfolio-chart.interface'; import type { PortfolioChart } from './portfolio-chart.interface';
import type { PortfolioDetails } from './portfolio-details.interface'; import type { PortfolioDetails } from './portfolio-details.interface';
import type { PortfolioDividends } from './portfolio-dividends.interface'; import type { PortfolioDividends } from './portfolio-dividends.interface';
@ -40,13 +41,14 @@ import type { AccountBalancesResponse } from './responses/account-balances-respo
import type { BenchmarkResponse } from './responses/benchmark-response.interface'; import type { BenchmarkResponse } from './responses/benchmark-response.interface';
import type { ResponseError } from './responses/errors.interface'; import type { ResponseError } from './responses/errors.interface';
import type { ImportResponse } from './responses/import-response.interface'; import type { ImportResponse } from './responses/import-response.interface';
import type { LookupResponse } from './responses/lookup-response.interface';
import type { OAuthResponse } from './responses/oauth-response.interface'; import type { OAuthResponse } from './responses/oauth-response.interface';
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface'; import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface'; import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface'; import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface';
import type { ScraperConfiguration } from './scraper-configuration.interface'; import type { ScraperConfiguration } from './scraper-configuration.interface';
import type { Statistics } from './statistics.interface'; import type { Statistics } from './statistics.interface';
import type { Subscription } from './subscription.interface'; import type { SubscriptionOffer } from './subscription-offer.interface';
import type { SymbolMetrics } from './symbol-metrics.interface'; import type { SymbolMetrics } from './symbol-metrics.interface';
import type { SystemMessage } from './system-message.interface'; import type { SystemMessage } from './system-message.interface';
import type { TabConfiguration } from './tab-configuration.interface'; import type { TabConfiguration } from './tab-configuration.interface';
@ -82,6 +84,8 @@ export {
InfoItem, InfoItem,
InvestmentItem, InvestmentItem,
LineChartItem, LineChartItem,
LookupItem,
LookupResponse,
OAuthResponse, OAuthResponse,
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
@ -102,8 +106,8 @@ export {
ResponseError, ResponseError,
ScraperConfiguration, ScraperConfiguration,
Statistics, Statistics,
SubscriptionOffer,
SystemMessage, SystemMessage,
Subscription,
SymbolMetrics, SymbolMetrics,
TabConfiguration, TabConfiguration,
ToggleOption, ToggleOption,

6
libs/common/src/lib/interfaces/info-item.interface.ts

@ -1,9 +1,9 @@
import { SubscriptionOffer } from '@ghostfolio/common/types'; import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { Platform, SymbolProfile } from '@prisma/client'; import { Platform, SymbolProfile } from '@prisma/client';
import { Statistics } from './statistics.interface'; import { Statistics } from './statistics.interface';
import { Subscription } from './subscription.interface'; import { SubscriptionOffer } from './subscription-offer.interface';
export interface InfoItem { export interface InfoItem {
baseCurrency: string; baseCurrency: string;
@ -18,5 +18,5 @@ export interface InfoItem {
platforms: Platform[]; platforms: Platform[];
statistics: Statistics; statistics: Statistics;
stripePublicKey?: string; stripePublicKey?: string;
subscriptions: { [offer in SubscriptionOffer]: Subscription }; subscriptionOffers: { [offer in SubscriptionOfferKey]: SubscriptionOffer };
} }

4
apps/api/src/app/symbol/interfaces/lookup-item.interface.ts → libs/common/src/lib/interfaces/lookup-item.interface.ts

@ -1,7 +1,7 @@
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { DataProviderInfo } from './data-provider-info.interface';
export interface LookupItem { export interface LookupItem {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;

5
libs/common/src/lib/interfaces/responses/lookup-response.interface.ts

@ -0,0 +1,5 @@
import { LookupItem } from '../lookup-item.interface';
export interface LookupResponse {
items: LookupItem[];
}

9
libs/common/src/lib/interfaces/subscription-offer.interface.ts

@ -0,0 +1,9 @@
import { StringValue } from 'ms';
export interface SubscriptionOffer {
coupon?: number;
couponId?: string;
durationExtension?: StringValue;
price: number;
priceId: string;
}

6
libs/common/src/lib/interfaces/subscription.interface.ts

@ -1,6 +0,0 @@
export interface Subscription {
coupon?: number;
couponId?: string;
price: number;
priceId: string;
}

2
libs/common/src/lib/interfaces/user-settings.interface.ts

@ -14,6 +14,8 @@ export interface UserSettings {
dateRange?: DateRange; dateRange?: DateRange;
emergencyFund?: number; emergencyFund?: number;
'filters.accounts'?: string[]; 'filters.accounts'?: string[];
'filters.dataSource'?: string;
'filters.symbol'?: string;
'filters.tags'?: string[]; 'filters.tags'?: string[];
holdingsViewMode?: HoldingsViewMode; holdingsViewMode?: HoldingsViewMode;
isExperimentalFeatures?: boolean; isExperimentalFeatures?: boolean;

4
libs/common/src/lib/interfaces/user.interface.ts

@ -1,4 +1,4 @@
import { SubscriptionOffer } from '@ghostfolio/common/types'; import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Account, Tag } from '@prisma/client'; import { Account, Tag } from '@prisma/client';
@ -20,7 +20,7 @@ export interface User {
systemMessage?: SystemMessage; systemMessage?: SystemMessage;
subscription: { subscription: {
expiresAt?: Date; expiresAt?: Date;
offer: SubscriptionOffer; offer: SubscriptionOfferKey;
type: SubscriptionType; type: SubscriptionType;
}; };
tags: (Tag & { isUsed: boolean })[]; tags: (Tag & { isUsed: boolean })[];

4
libs/common/src/lib/types/index.ts

@ -15,7 +15,7 @@ import type { MarketState } from './market-state.type';
import type { Market } from './market.type'; import type { Market } from './market.type';
import type { OrderWithAccount } from './order-with-account.type'; import type { OrderWithAccount } from './order-with-account.type';
import type { RequestWithUser } from './request-with-user.type'; import type { RequestWithUser } from './request-with-user.type';
import type { SubscriptionOffer } from './subscription-offer.type'; import type { SubscriptionOfferKey } from './subscription-offer-key.type';
import type { UserWithSettings } from './user-with-settings.type'; import type { UserWithSettings } from './user-with-settings.type';
import type { ViewMode } from './view-mode.type'; import type { ViewMode } from './view-mode.type';
@ -37,7 +37,7 @@ export type {
MarketState, MarketState,
OrderWithAccount, OrderWithAccount,
RequestWithUser, RequestWithUser,
SubscriptionOffer, SubscriptionOfferKey,
UserWithSettings, UserWithSettings,
ViewMode ViewMode
}; };

2
libs/common/src/lib/types/subscription-offer.type.ts → libs/common/src/lib/types/subscription-offer-key.type.ts

@ -1,4 +1,4 @@
export type SubscriptionOffer = export type SubscriptionOfferKey =
| 'default' | 'default'
| 'renewal' | 'renewal'
| 'renewal-early-bird-2023' | 'renewal-early-bird-2023'

4
libs/common/src/lib/types/user-with-settings.type.ts

@ -1,5 +1,5 @@
import { UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
import { SubscriptionOffer } from '@ghostfolio/common/types'; import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Access, Account, Settings, User } from '@prisma/client'; import { Access, Account, Settings, User } from '@prisma/client';
@ -13,7 +13,7 @@ export type UserWithSettings = User & {
Settings: Settings & { settings: UserSettings }; Settings: Settings & { settings: UserSettings };
subscription?: { subscription?: {
expiresAt?: Date; expiresAt?: Date;
offer: SubscriptionOffer; offer: SubscriptionOfferKey;
type: SubscriptionType; type: SubscriptionType;
}; };
}; };

136
libs/ui/src/lib/assistant/assistant.component.ts

@ -1,7 +1,9 @@
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component'; import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { Filter, User } from '@ghostfolio/common/interfaces'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -35,7 +37,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatMenuTrigger } from '@angular/material/menu'; import { MatMenuTrigger } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { Account, AssetClass } from '@prisma/client'; import { Account, AssetClass, DataSource } from '@prisma/client';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs'; import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
import { import {
@ -61,6 +63,7 @@ import {
FormsModule, FormsModule,
GfAssetProfileIconComponent, GfAssetProfileIconComponent,
GfAssistantListItemComponent, GfAssistantListItemComponent,
GfSymbolModule,
MatButtonModule, MatButtonModule,
MatFormFieldModule, MatFormFieldModule,
MatSelectModule, MatSelectModule,
@ -130,10 +133,12 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
public dateRangeFormControl = new FormControl<string>(undefined); public dateRangeFormControl = new FormControl<string>(undefined);
public dateRangeOptions: IDateRangeOption[] = []; public dateRangeOptions: IDateRangeOption[] = [];
public filterForm = this.formBuilder.group({ public filterForm = this.formBuilder.group({
account: new FormControl<string[]>(undefined), account: new FormControl<string>(undefined),
assetClass: new FormControl<string[]>(undefined), assetClass: new FormControl<string>(undefined),
tag: new FormControl<string[]>(undefined) holding: new FormControl<PortfolioPosition>(undefined),
tag: new FormControl<string>(undefined)
}); });
public holdings: PortfolioPosition[] = [];
public isLoading = false; public isLoading = false;
public isOpen = false; public isOpen = false;
public placeholder = $localize`Find holding...`; public placeholder = $localize`Find holding...`;
@ -144,7 +149,13 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}; };
public tags: Filter[] = []; public tags: Filter[] = [];
private filterTypes: Filter['type'][] = ['ACCOUNT', 'ASSET_CLASS', 'TAG']; private filterTypes: Filter['type'][] = [
'ACCOUNT',
'ASSET_CLASS',
'DATA_SOURCE',
'SYMBOL',
'TAG'
];
private keyManager: FocusKeyManager<GfAssistantListItemComponent>; private keyManager: FocusKeyManager<GfAssistantListItemComponent>;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -156,6 +167,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.initializeFilterForm();
this.assetClasses = Object.keys(AssetClass).map((assetClass) => { this.assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { return {
id: assetClass, id: assetClass,
@ -263,16 +276,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.filterForm.enable({ emitEvent: false }); this.filterForm.enable({ emitEvent: false });
} }
this.filterForm.setValue( this.initializeFilterForm();
{
account: this.user?.settings?.['filters.accounts'] ?? null,
assetClass: this.user?.settings?.['filters.assetClasses'] ?? null,
tag: this.user?.settings?.['filters.tags'] ?? null
},
{
emitEvent: false
}
);
this.tags = this.tags =
this.user?.tags this.user?.tags
@ -298,6 +302,19 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}); });
} }
public holdingComparisonFunction(
option: PortfolioPosition,
value: PortfolioPosition
): boolean {
if (value === null) {
return false;
}
return (
getAssetProfileIdentifier(option) === getAssetProfileIdentifier(value)
);
}
public async initialize() { public async initialize() {
this.isLoading = true; this.isLoading = true;
this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap(); this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap();
@ -322,28 +339,28 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
} }
public onApplyFilters() { public onApplyFilters() {
const accountFilters = this.filtersChanged.emit([
this.filterForm {
.get('account') id: this.filterForm.get('account').value,
.value?.reduce( type: 'ACCOUNT'
(arr, val) => [...arr, { id: val, type: 'ACCOUNT' }], },
[] {
) ?? []; id: this.filterForm.get('assetClass').value,
const assetClassFilters = type: 'ASSET_CLASS'
this.filterForm },
.get('assetClass') {
.value?.reduce( id: this.filterForm.get('holding').value?.dataSource,
(arr, val) => [...arr, { id: val, type: 'ASSET_CLASS' }], type: 'DATA_SOURCE'
[] },
) ?? []; {
const tagFilters = id: this.filterForm.get('holding').value?.symbol,
this.filterForm type: 'SYMBOL'
.get('tag') },
.value?.reduce((arr, val) => [...arr, { id: val, type: 'TAG' }], []) ?? {
[]; id: this.filterForm.get('tag').value,
let filters = [...accountFilters, ...assetClassFilters]; type: 'TAG'
filters = [...filters, ...tagFilters]; }
this.filtersChanged.emit(filters); ]);
this.onCloseAssistant(); this.onCloseAssistant();
} }
@ -481,4 +498,47 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
takeUntil(this.unsubscribeSubject) takeUntil(this.unsubscribeSubject)
); );
} }
private initializeFilterForm() {
this.dataService
.fetchPortfolioHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings
.filter(({ assetSubClass }) => {
return !['CASH'].includes(assetSubClass);
})
.sort((a, b) => {
return a.name?.localeCompare(b.name);
});
this.setFilterFormValues();
});
}
private setFilterFormValues() {
const dataSource = this.user?.settings?.[
'filters.dataSource'
] as DataSource;
const symbol = this.user?.settings?.['filters.symbol'];
const selectedHolding = this.holdings.find((holding) => {
return (
getAssetProfileIdentifier({
dataSource: holding.dataSource,
symbol: holding.symbol
}) === getAssetProfileIdentifier({ dataSource, symbol })
);
});
this.filterForm.setValue(
{
account: this.user?.settings?.['filters.accounts']?.[0] ?? null,
assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null,
holding: selectedHolding ?? null,
tag: this.user?.settings?.['filters.tags']?.[0] ?? null
},
{
emitEvent: false
}
);
}
} }

28
libs/ui/src/lib/assistant/assistant.html

@ -122,6 +122,34 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Holding</mat-label>
<mat-select
formControlName="holding"
[compareWith]="holdingComparisonFunction"
>
<mat-select-trigger>{{
filterForm.get('holding')?.value?.name
}}</mat-select-trigger>
<mat-option [value]="null" />
@for (holding of holdings; track holding.name) {
<mat-option [value]="holding">
<div class="line-height-1 text-truncate">
<span
><b>{{ holding.name }}</b></span
>
<br />
<small class="text-muted"
>{{ holding.symbol | gfSymbol }} ·
{{ holding.currency }}</small
>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3"> <div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tags</mat-label> <mat-label i18n>Tags</mat-label>

2
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

@ -1,6 +1,6 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { LookupItem } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { AbstractMatFormField } from '@ghostfolio/ui/shared/abstract-mat-form-field'; import { AbstractMatFormField } from '@ghostfolio/ui/shared/abstract-mat-form-field';

15
libs/ui/src/lib/treemap-chart/treemap-chart.component.ts

@ -261,12 +261,21 @@ export class GfTreemapChartComponent
display: true, display: true,
font: [{ size: 16 }, { lineHeight: 1.5, size: 14 }], font: [{ size: 16 }, { lineHeight: 1.5, size: 14 }],
formatter: (ctx) => { formatter: (ctx) => {
const netPerformancePercentWithCurrencyEffect = // Round to 4 decimal places
ctx.raw._data.netPerformancePercentWithCurrencyEffect; let netPerformancePercentWithCurrencyEffect =
Math.round(
ctx.raw._data.netPerformancePercentWithCurrencyEffect * 10000
) / 10000;
if (Math.abs(netPerformancePercentWithCurrencyEffect) === 0) {
netPerformancePercentWithCurrencyEffect = Math.abs(
netPerformancePercentWithCurrencyEffect
);
}
return [ return [
ctx.raw._data.symbol, ctx.raw._data.symbol,
`${netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''}${(ctx.raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%` `${netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''}${(netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`
]; ];
}, },
hoverColor: undefined, hoverColor: undefined,

461
package-lock.json

File diff suppressed because it is too large

12
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.119.0", "version": "2.122.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",
@ -90,7 +90,7 @@
"@prisma/client": "5.21.1", "@prisma/client": "5.21.1",
"@simplewebauthn/browser": "9.0.1", "@simplewebauthn/browser": "9.0.1",
"@simplewebauthn/server": "9.0.3", "@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "3.5.0", "@stripe/stripe-js": "4.9.0",
"alphavantage": "2.2.0", "alphavantage": "2.2.0",
"await-lock": "^2.2.2", "await-lock": "^2.2.2",
"big.js": "6.2.1", "big.js": "6.2.1",
@ -109,7 +109,7 @@
"class-validator": "0.14.1", "class-validator": "0.14.1",
"color": "4.2.3", "color": "4.2.3",
"countries-and-timezones": "3.4.1", "countries-and-timezones": "3.4.1",
"countries-list": "3.1.0", "countries-list": "3.1.1",
"countup.js": "2.8.0", "countup.js": "2.8.0",
"date-fns": "3.6.0", "date-fns": "3.6.0",
"envalid": "7.3.1", "envalid": "7.3.1",
@ -125,8 +125,8 @@
"ng-extract-i18n-merge": "2.12.0", "ng-extract-i18n-merge": "2.12.0",
"ngx-device-detector": "8.0.0", "ngx-device-detector": "8.0.0",
"ngx-markdown": "18.0.0", "ngx-markdown": "18.0.0",
"ngx-skeleton-loader": "7.0.0", "ngx-skeleton-loader": "9.0.0",
"ngx-stripe": "18.0.0", "ngx-stripe": "18.1.0",
"open-color": "1.9.1", "open-color": "1.9.1",
"papaparse": "5.3.1", "papaparse": "5.3.1",
"passport": "0.7.0", "passport": "0.7.0",
@ -134,7 +134,7 @@
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rxjs": "7.5.6", "rxjs": "7.5.6",
"stripe": "15.11.0", "stripe": "17.3.0",
"svgmap": "2.6.0", "svgmap": "2.6.0",
"twitter-api-v2": "1.14.2", "twitter-api-v2": "1.14.2",
"uuid": "9.0.1", "uuid": "9.0.1",

2
prisma/migrations/20241029190323_added_data_provider_ghostfolio_daily_requests_to_analytics/migration.sql

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Analytics" ADD COLUMN "dataProviderGhostfolioDailyRequests" INTEGER NOT NULL DEFAULT 0;

5
prisma/migrations/20241102121004_added_last_request_at_to_analytics/migration.sql

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Analytics" ADD COLUMN "lastRequestAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- CreateIndex
CREATE INDEX "Analytics_lastRequestAt_idx" ON "Analytics"("lastRequestAt");

15
prisma/schema.prisma

@ -65,12 +65,15 @@ model AccountBalance {
} }
model Analytics { model Analytics {
activityCount Int @default(0) activityCount Int @default(0)
country String? country String?
updatedAt DateTime @updatedAt dataProviderGhostfolioDailyRequests Int @default(0)
userId String @id lastRequestAt DateTime @default(now())
User User @relation(fields: [userId], onDelete: Cascade, references: [id]) updatedAt DateTime @updatedAt
userId String @id
User User @relation(fields: [userId], onDelete: Cascade, references: [id])
@@index([lastRequestAt])
@@index([updatedAt]) @@index([updatedAt])
} }

2
test/import/ok-derived-currency.json

@ -23,7 +23,7 @@
"unitPrice": 10875.00, "unitPrice": 10875.00,
"currency": "ZAc", "currency": "ZAc",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "2024-06-27T22:00:00.000Z", "date": "2024-06-28T00:00:00.000Z",
"symbol": "JSE.JO" "symbol": "JSE.JO"
} }
] ]

6
test/import/ok-vti-buy-long-history.json

@ -11,7 +11,7 @@
"unitPrice": 65.31, "unitPrice": 65.31,
"currency": "USD", "currency": "USD",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "2012-01-02T22:00:00.000Z", "date": "2012-01-03T00:00:00.000Z",
"symbol": "VTI" "symbol": "VTI"
}, },
{ {
@ -21,7 +21,7 @@
"unitPrice": 65.40, "unitPrice": 65.40,
"currency": "USD", "currency": "USD",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "2011-01-02T22:00:00.000Z", "date": "2011-01-03T00:00:00.000Z",
"symbol": "VTI" "symbol": "VTI"
}, },
{ {
@ -31,7 +31,7 @@
"unitPrice": 57.05, "unitPrice": 57.05,
"currency": "USD", "currency": "USD",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "2010-01-03T22:00:00.000Z", "date": "2010-01-04T00:00:00.000Z",
"symbol": "VTI" "symbol": "VTI"
} }
] ]

8
test/import/ok-without-accounts.json

@ -11,7 +11,7 @@
"unitPrice": 0, "unitPrice": 0,
"currency": "USD", "currency": "USD",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "2050-06-05T22:00:00.000Z", "date": "2050-06-06T00:00:00.000Z",
"symbol": "MSFT" "symbol": "MSFT"
}, },
{ {
@ -21,7 +21,7 @@
"unitPrice": 500000, "unitPrice": 500000,
"currency": "USD", "currency": "USD",
"dataSource": "MANUAL", "dataSource": "MANUAL",
"date": "2021-12-31T22:00:00.000Z", "date": "2022-01-01T00:00:00.000Z",
"symbol": "Penthouse Apartment" "symbol": "Penthouse Apartment"
}, },
{ {
@ -31,7 +31,7 @@
"unitPrice": 0.62, "unitPrice": 0.62,
"currency": "USD", "currency": "USD",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "2021-11-16T22:00:00.000Z", "date": "2021-11-17T00:00:00.000Z",
"symbol": "MSFT" "symbol": "MSFT"
}, },
{ {
@ -41,7 +41,7 @@
"unitPrice": 298.58, "unitPrice": 298.58,
"currency": "USD", "currency": "USD",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "2021-09-15T22:00:00.000Z", "date": "2021-09-16T00:00:00.000Z",
"symbol": "MSFT" "symbol": "MSFT"
} }
] ]

10
test/import/ok.json

@ -23,7 +23,7 @@
"unitPrice": 0, "unitPrice": 0,
"currency": "USD", "currency": "USD",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "2050-06-05T22:00:00.000Z", "date": "2050-06-06T00:00:00.000Z",
"symbol": "US5949181045" "symbol": "US5949181045"
}, },
{ {
@ -35,7 +35,7 @@
"unitPrice": 500000, "unitPrice": 500000,
"currency": "USD", "currency": "USD",
"dataSource": "MANUAL", "dataSource": "MANUAL",
"date": "2021-12-31T22:00:00.000Z", "date": "2022-01-01T00:00:00.000Z",
"symbol": "Penthouse Apartment" "symbol": "Penthouse Apartment"
}, },
{ {
@ -47,7 +47,7 @@
"unitPrice": 0.62, "unitPrice": 0.62,
"currency": "USD", "currency": "USD",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "2021-11-16T22:00:00.000Z", "date": "2021-11-17T00:00:00.000Z",
"symbol": "MSFT" "symbol": "MSFT"
}, },
{ {
@ -59,7 +59,7 @@
"unitPrice": 298.58, "unitPrice": 298.58,
"currency": "USD", "currency": "USD",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "2021-09-15T22:00:00.000Z", "date": "2021-09-16T00:00:00.000Z",
"symbol": "MSFT" "symbol": "MSFT"
}, },
{ {
@ -71,7 +71,7 @@
"unitPrice": 0, "unitPrice": 0,
"currency": "USD", "currency": "USD",
"dataSource": "MANUAL", "dataSource": "MANUAL",
"date": "2021-08-31T22:00:00.000Z", "date": "2021-09-01T00:00:00.000Z",
"symbol": "Account Opening Fee" "symbol": "Account Opening Fee"
} }
] ]

2
test/import/unavailable-exchange-rate.json

@ -12,7 +12,7 @@
"unitPrice": 0, "unitPrice": 0,
"currency": "EUR", "currency": "EUR",
"dataSource": "YAHOO", "dataSource": "YAHOO",
"date": "1990-01-01T22:00:00.000Z", "date": "1990-01-01T00:00:00.000Z",
"symbol": "MSFT" "symbol": "MSFT"
} }
] ]

Loading…
Cancel
Save