Browse Source

Merge branch 'ghostfolio:main' into main

pull/5027/head
dandevaud 2 years ago
committed by GitHub
parent
commit
be70c95ea1
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 31
      CHANGELOG.md
  2. 2
      DEVELOPMENT.md
  3. 2
      README.md
  4. 3
      apps/api/project.json
  5. 4
      apps/api/src/app/account/account.controller.ts
  6. 25
      apps/api/src/app/portfolio/portfolio.controller.ts
  7. 292
      apps/api/src/app/portfolio/portfolio.service.ts
  8. 8
      apps/api/src/app/subscription/subscription.service.ts
  9. 2
      apps/api/src/app/user/user.service.ts
  10. 6
      apps/api/src/decorators/has-permission.decorator.ts
  11. 55
      apps/api/src/guards/has-permission.guard.spec.ts
  12. 37
      apps/api/src/guards/has-permission.guard.ts
  13. 3
      apps/api/src/services/account-balance/account-balance.module.ts
  14. 52
      apps/api/src/services/account-balance/account-balance.service.ts
  15. 24
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  16. 2
      apps/api/src/services/data-provider/data-provider.service.ts
  17. 16
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  18. 16
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  19. 8
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  20. 7
      apps/client/project.json
  21. 6
      apps/client/src/app/adapter/custom-date-adapter.ts
  22. 18
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  23. 25
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  24. 4
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts
  25. 4
      apps/client/src/app/pages/about/overview/about-overview-page.html
  26. 5
      apps/client/src/app/pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.html
  27. 4
      apps/client/src/app/pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.html
  28. 6
      apps/client/src/app/pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.html
  29. 4
      apps/client/src/app/pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.html
  30. 6
      apps/client/src/app/pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.html
  31. 4
      apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.html
  32. 2
      apps/client/src/app/pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.html
  33. 2
      apps/client/src/app/pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.html
  34. 2
      apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html
  35. 12
      apps/client/src/app/pages/faq/faq-page.html
  36. 5
      apps/client/src/app/pages/i18n/i18n-page.html
  37. 2
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  38. 2
      apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.html
  39. 11
      apps/client/src/app/pages/resources/personal-finance-tools/product-page-template.html
  40. 1
      apps/client/src/app/pages/resources/personal-finance-tools/products.ts
  41. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/allvue-systems-page.component.ts
  42. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/altoo-page.component.ts
  43. 18
      apps/client/src/app/pages/resources/personal-finance-tools/products/base-page.component.ts
  44. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/basil-finance-page.component.ts
  45. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/beanvest-page.component.ts
  46. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/capitally-page.component.ts
  47. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/capmon-page.component.ts
  48. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/compound-planning-page.component.ts
  49. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/copilot-money-page.component.ts
  50. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/de.fi-page.component.ts
  51. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/delta-page.component.ts
  52. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/divvydiary-page.component.ts
  53. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/eightfigures-page.component.ts
  54. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/empower-page.component.ts
  55. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/exirio-page.component.ts
  56. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/finary-page.component.ts
  57. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/finwise-page.component.ts
  58. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/folishare-page.component.ts
  59. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/getquin-page.component.ts
  60. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/gospatz-page.component.ts
  61. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/intuit-mint-page.component.ts
  62. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/justetf-page.component.ts
  63. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/kubera-page.component.ts
  64. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/magnifi-page.component.ts
  65. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/markets.sh-page.component.ts
  66. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/maybe-finance-page.component.ts
  67. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/monarch-money-page.component.ts
  68. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/monse-page.component.ts
  69. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/parqet-page.component.ts
  70. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/plannix-page.component.ts
  71. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/portfolio-dividend-tracker-page.component.ts
  72. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/portseido-page.component.ts
  73. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/projectionlab-page.component.ts
  74. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/rocket-money-page.component.ts
  75. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/seeking-alpha-page.component.ts
  76. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/sharesight-page.component.ts
  77. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/simple-portfolio-page.component.ts
  78. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/snowball-analytics-page.component.ts
  79. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/stockle-page.component.ts
  80. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/stockmarketeye-page.component.ts
  81. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/sumio-page.component.ts
  82. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/tiller-page.component.ts
  83. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/utluna-page.component.ts
  84. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/vyzer-page.component.ts
  85. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/wealthica-page.component.ts
  86. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/whal-page.component.ts
  87. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/yeekatee-page.component.ts
  88. 3
      apps/client/src/app/pages/resources/personal-finance-tools/products/ynab-page.component.ts
  89. 7
      apps/client/src/app/services/data.service.ts
  90. 13
      apps/client/src/assets/oss-friends.json
  91. 1080
      apps/client/src/locales/messages.de.xlf
  92. 1080
      apps/client/src/locales/messages.es.xlf
  93. 1080
      apps/client/src/locales/messages.fr.xlf
  94. 1080
      apps/client/src/locales/messages.it.xlf
  95. 1080
      apps/client/src/locales/messages.nl.xlf
  96. 1080
      apps/client/src/locales/messages.pl.xlf
  97. 1080
      apps/client/src/locales/messages.pt.xlf
  98. 1048
      apps/client/src/locales/messages.tr.xlf
  99. 1046
      apps/client/src/locales/messages.xlf
  100. 3
      libs/common/project.json

31
CHANGELOG.md

@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Added a historical cash balances table to the account detail dialog
- Introduced a `HasPermission` annotation for endpoints
### Changed
- Respected the `withExcludedAccounts` flag in the account balance time series
### Fixed
- Changed the mechanism of the `INTRADAY` data gathering to operate synchronously avoiding database deadlocks
## 2.27.1 - 2023-11-28
### Changed
- Reverted `Nx` from version `17.1.3` to `17.0.2`
## 2.27.0 - 2023-11-26
### Changed
- Extended the chart in the account detail dialog by historical cash balances
- Improved the error log for a timeout in the data source request
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `16.2.12` to `17.0.4`
- Upgraded `Nx` from version `17.0.2` to `17.1.3`
## 2.26.0 - 2023-11-24
### Changed
- Upgraded `prisma` from version `5.5.2` to `5.6.0`

2
DEVELOPMENT.md

@ -32,7 +32,7 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
1. Run `yarn nx migrate latest`
1. Make sure `package.json` changes make sense and then run `yarn install`
1. Run `yarn nx migrate --run-migrations`
1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
### Prisma

2
README.md

@ -272,7 +272,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).

3
apps/api/project.json

@ -47,8 +47,7 @@
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true
"jestConfig": "apps/api/jest.config.ts"
},
"outputs": ["{workspaceRoot}/coverage/apps/api"]
}

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

@ -128,8 +128,8 @@ export class AccountController {
@Param('id') id: string
): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({
accountId: id,
userId: this.request.user.id
filters: [{ id, type: 'ACCOUNT' }],
user: this.request.user
});
}

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

@ -381,14 +381,32 @@ export class PortfolioController {
this.userService.isRestrictedView(this.request.user)
) {
performanceInformation.chart = performanceInformation.chart.map(
({ date, netPerformanceInPercentage, totalInvestment, value }) => {
({
date,
netPerformanceInPercentage,
netWorth,
totalInvestment,
value
}) => {
return {
date,
netPerformanceInPercentage,
totalInvestment: new Big(totalInvestment)
netWorthInPercentage:
performanceInformation.performance.currentNetWorth === 0
? 0
: new Big(netWorth)
.div(performanceInformation.performance.currentNetWorth)
.toNumber(),
totalInvestment:
performanceInformation.performance.totalInvestment === 0
? 0
: new Big(totalInvestment)
.div(performanceInformation.performance.totalInvestment)
.toNumber(),
valueInPercentage: new Big(value)
valueInPercentage:
performanceInformation.performance.currentValue === 0
? 0
: new Big(value)
.div(performanceInformation.performance.currentValue)
.toNumber()
};
@ -400,6 +418,7 @@ export class PortfolioController {
[
'currentGrossPerformance',
'currentNetPerformance',
'currentNetWorth',
'currentValue',
'totalInvestment'
]

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

@ -12,6 +12,7 @@ import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/ap
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
@ -67,7 +68,9 @@ import {
isBefore,
isSameMonth,
isSameYear,
isValid,
max,
min,
parseISO,
set,
setDayOfYear,
@ -75,7 +78,7 @@ import {
subMonths,
subYears
} from 'date-fns';
import { isEmpty, sortBy, uniq, uniqBy } from 'lodash';
import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash';
import {
HistoricalDataContainer,
@ -92,6 +95,7 @@ const europeMarkets = require('../../assets/countries/europe-markets.json');
@Injectable()
export class PortfolioService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService,
@ -115,8 +119,12 @@ export class PortfolioService {
}): Promise<AccountWithValue[]> {
const where: Prisma.AccountWhereInput = { userId: userId };
if (filters?.[0].id && filters?.[0].type === 'ACCOUNT') {
where.id = filters[0].id;
const accountFilter = filters?.find(({ type }) => {
return type === 'ACCOUNT';
});
if (accountFilter) {
where.id = accountFilter.id;
}
const [accounts, details] = await Promise.all([
@ -268,6 +276,13 @@ export class PortfolioService {
includeDrafts: true
});
if (transactionPoints.length === 0) {
return {
investments: [],
streaks: { currentStreak: 0, longestStreak: 0 }
};
}
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService,
@ -275,12 +290,6 @@ export class PortfolioService {
});
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return {
investments: [],
streaks: { currentStreak: 0, longestStreak: 0 }
};
}
let investments: InvestmentItem[];
@ -368,67 +377,6 @@ export class PortfolioService {
};
}
public async getChart({
dateRange = 'max',
filters,
impersonationId,
userCurrency,
userId,
withExcludedAccounts = false
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
userCurrency: string;
userId: string;
withExcludedAccounts?: boolean;
}): Promise<HistoricalDataContainer> {
userId = await this.getUserId(impersonationId, userId);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
filters,
userId,
withExcludedAccounts
});
const portfolioCalculator = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return {
isAllTimeHigh: false,
isAllTimeLow: false,
items: []
};
}
const endDate = new Date();
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(dateRange, portfolioStart);
const daysInMarket = differenceInDays(new Date(), startDate);
const step = Math.round(
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)
);
const items = await portfolioCalculator.getChartData(
startDate,
endDate,
step
);
return {
items,
isAllTimeHigh: false,
isAllTimeLow: false
};
}
public async getDetails({
dateRange = 'max',
filters,
@ -1118,12 +1066,6 @@ export class PortfolioService {
userId
});
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
if (transactionPoints?.length <= 0) {
return {
hasErrors: false,
@ -1131,6 +1073,12 @@ export class PortfolioService {
};
}
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
@ -1217,6 +1165,31 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const accountBalances = await this.accountBalanceService.getAccountBalances(
{ filters, user, withExcludedAccounts }
);
let accountBalanceItems: HistoricalDataItem[] = Object.values(
// Reduce the array to a map with unique dates as keys
accountBalances.balances.reduce(
(
map: { [date: string]: HistoricalDataItem },
{ date, valueInBaseCurrency }
) => {
const formattedDate = format(date, DATE_FORMAT);
// Store the item in the map, overwriting if the date already exists
map[formattedDate] = {
date: formattedDate,
value: valueInBaseCurrency
};
return map;
},
{}
)
);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
filters,
@ -1230,7 +1203,7 @@ export class PortfolioService {
orders: portfolioOrders
});
if (transactionPoints?.length <= 0) {
if (accountBalanceItems?.length <= 0 && transactionPoints?.length <= 0) {
return {
chart: [],
firstOrderDate: undefined,
@ -1240,6 +1213,7 @@ export class PortfolioService {
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentNetWorth: 0,
currentValue: 0,
totalInvestment: 0
}
@ -1248,7 +1222,15 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const portfolioStart = min(
[
parseDate(accountBalanceItems[0]?.date),
parseDate(transactionPoints[0]?.date)
].filter((date) => {
return isValid(date);
})
);
const startDate = this.getStartDate(dateRange, portfolioStart);
const {
currentValue,
@ -1266,17 +1248,17 @@ export class PortfolioService {
let currentNetPerformance = netPerformance;
let currentNetPerformancePercent = netPerformancePercentage;
const historicalDataContainer = await this.getChart({
const { items } = await this.getChart({
dateRange,
filters,
impersonationId,
portfolioOrders,
transactionPoints,
userCurrency,
userId,
withExcludedAccounts
userId
});
const itemOfToday = historicalDataContainer.items.find((item) => {
return item.date === format(new Date(), DATE_FORMAT);
const itemOfToday = items.find(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
});
if (itemOfToday) {
@ -1286,34 +1268,42 @@ export class PortfolioService {
).div(100);
}
accountBalanceItems = accountBalanceItems.filter(({ date }) => {
return !isBefore(parseDate(date), startDate);
});
const accountBalanceItemOfToday = accountBalanceItems.find(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
});
if (!accountBalanceItemOfToday) {
accountBalanceItems.push({
date: format(new Date(), DATE_FORMAT),
value: last(accountBalanceItems)?.value ?? 0
});
}
const mergedHistoricalDataItems = this.mergeHistoricalDataItems(
accountBalanceItems,
items
);
const currentHistoricalDataItem = last(mergedHistoricalDataItems);
const currentNetWorth = currentHistoricalDataItem?.netWorth ?? 0;
return {
errors,
hasErrors,
chart: historicalDataContainer.items.map(
({
date,
netPerformance: netPerformanceOfItem,
netPerformanceInPercentage,
totalInvestment: totalInvestmentOfItem,
value
}) => {
return {
date,
netPerformanceInPercentage,
value,
netPerformance: netPerformanceOfItem,
totalInvestment: totalInvestmentOfItem
};
}
),
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date),
chart: mergedHistoricalDataItems,
firstOrderDate: parseDate(items[0]?.date),
performance: {
currentValue: currentValue.toNumber(),
currentNetWorth,
currentGrossPerformance: currentGrossPerformance.toNumber(),
currentGrossPerformancePercent:
currentGrossPerformancePercent.toNumber(),
currentNetPerformance: currentNetPerformance.toNumber(),
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(),
currentValue: currentValue.toNumber(),
totalInvestment: totalInvestment.toNumber()
}
};
@ -1467,6 +1457,62 @@ export class PortfolioService {
return cashPositions;
}
private async getChart({
dateRange = 'max',
impersonationId,
portfolioOrders,
transactionPoints,
userCurrency,
userId
}: {
dateRange?: DateRange;
impersonationId: string;
portfolioOrders: PortfolioOrder[];
transactionPoints: TransactionPoint[];
userCurrency: string;
userId: string;
}): Promise<HistoricalDataContainer> {
if (transactionPoints.length === 0) {
return {
isAllTimeHigh: false,
isAllTimeLow: false,
items: []
};
}
userId = await this.getUserId(impersonationId, userId);
const portfolioCalculator = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints);
const endDate = new Date();
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(dateRange, portfolioStart);
const daysInMarket = differenceInDays(new Date(), startDate);
const step = Math.round(
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)
);
const items = await portfolioCalculator.getChartData(
startDate,
endDate,
step
);
return {
items,
isAllTimeHigh: false,
isAllTimeLow: false
};
}
private getDividendsByGroup({
dividends,
groupBy
@ -2120,4 +2166,44 @@ export class PortfolioService {
return { accounts, platforms };
}
private mergeHistoricalDataItems(
accountBalanceItems: HistoricalDataItem[],
performanceChartItems: HistoricalDataItem[]
): HistoricalDataItem[] {
const historicalDataItemsMap: { [date: string]: HistoricalDataItem } = {};
let latestAccountBalance = 0;
for (const item of accountBalanceItems.concat(performanceChartItems)) {
const isAccountBalanceItem = accountBalanceItems.includes(item);
const totalAccountBalance = isAccountBalanceItem
? item.value
: latestAccountBalance;
if (isAccountBalanceItem && performanceChartItems.length > 0) {
latestAccountBalance = item.value;
} else {
historicalDataItemsMap[item.date] = {
...item,
totalAccountBalance,
netWorth:
(isAccountBalanceItem ? 0 : item.value) + totalAccountBalance
};
}
}
// Convert to an array and sort by date in ascending order
const historicalDataItems = Object.keys(historicalDataItemsMap).map(
(date) => {
return historicalDataItemsMap[date];
}
);
historicalDataItems.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
return historicalDataItems;
}
}

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

@ -111,14 +111,14 @@ export class SubscriptionService {
aSubscriptions: Subscription[]
): UserWithSettings['subscription'] {
if (aSubscriptions.length > 0) {
const latestSubscription = aSubscriptions.reduce((a, b) => {
const { expiresAt, price } = aSubscriptions.reduce((a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
});
return {
expiresAt: latestSubscription.expiresAt,
offer: latestSubscription.price === 0 ? 'default' : 'renewal',
type: isBefore(new Date(), latestSubscription.expiresAt)
expiresAt,
offer: price ? 'renewal' : 'default',
type: isBefore(new Date(), expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};

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

@ -60,7 +60,7 @@ export class UserService {
PROPERTY_SYSTEM_MESSAGE
)) as SystemMessage;
if (systemMessageProperty?.targetGroups?.includes(subscription.type)) {
if (systemMessageProperty?.targetGroups?.includes(subscription?.type)) {
systemMessage = systemMessageProperty;
}

6
apps/api/src/decorators/has-permission.decorator.ts

@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const HAS_PERMISSION_KEY = 'has_permission';
export function HasPermission(permission: string) {
return SetMetadata(HAS_PERMISSION_KEY, permission);
}

55
apps/api/src/guards/has-permission.guard.spec.ts

@ -0,0 +1,55 @@
import { HttpException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host';
import { Test, TestingModule } from '@nestjs/testing';
import { HasPermissionGuard } from './has-permission.guard';
describe('HasPermissionGuard', () => {
let guard: HasPermissionGuard;
let reflector: Reflector;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [HasPermissionGuard, Reflector]
}).compile();
guard = module.get<HasPermissionGuard>(HasPermissionGuard);
reflector = module.get<Reflector>(Reflector);
});
function setupReflectorSpy(returnValue: string) {
jest.spyOn(reflector, 'get').mockReturnValue(returnValue);
}
function createMockExecutionContext(permissions: string[]) {
return new ExecutionContextHost([
{
user: {
permissions // Set user permissions based on the argument
}
}
]);
}
it('should deny access if the user does not have any permission', () => {
setupReflectorSpy('required-permission');
const noPermissions = createMockExecutionContext([]);
expect(() => guard.canActivate(noPermissions)).toThrow(HttpException);
});
it('should deny access if the user has the wrong permission', () => {
setupReflectorSpy('required-permission');
const wrongPermission = createMockExecutionContext(['wrong-permission']);
expect(() => guard.canActivate(wrongPermission)).toThrow(HttpException);
});
it('should allow access if the user has the required permission', () => {
setupReflectorSpy('required-permission');
const rightPermission = createMockExecutionContext(['required-permission']);
expect(guard.canActivate(rightPermission)).toBe(true);
});
});

37
apps/api/src/guards/has-permission.guard.ts

@ -0,0 +1,37 @@
import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator';
import { hasPermission } from '@ghostfolio/common/permissions';
import {
CanActivate,
ExecutionContext,
HttpException,
Injectable
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
export class HasPermissionGuard implements CanActivate {
public constructor(private reflector: Reflector) {}
public canActivate(context: ExecutionContext): boolean {
const requiredPermission = this.reflector.get<string>(
HAS_PERMISSION_KEY,
context.getHandler()
);
if (!requiredPermission) {
return true; // No specific permissions required
}
const { user } = context.switchToHttp().getRequest();
if (!user || !hasPermission(user.permissions, requiredPermission)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return true;
}
}

3
apps/api/src/services/account-balance/account-balance.module.ts

@ -1,10 +1,11 @@
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
@Module({
exports: [AccountBalanceService],
imports: [PrismaModule],
imports: [ExchangeRateDataModule, PrismaModule],
providers: [AccountBalanceService]
})
export class AccountBalanceModule {}

52
apps/api/src/services/account-balance/account-balance.service.ts

@ -1,11 +1,16 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccountBalancesResponse } from '@ghostfolio/common/interfaces';
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { AccountBalance, Prisma } from '@prisma/client';
@Injectable()
export class AccountBalanceService {
public constructor(private readonly prismaService: PrismaService) {}
public constructor(
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
public async createAccountBalance(
data: Prisma.AccountBalanceCreateInput
@ -16,27 +21,52 @@ export class AccountBalanceService {
}
public async getAccountBalances({
accountId,
userId
filters,
user,
withExcludedAccounts
}: {
accountId: string;
userId: string;
filters?: Filter[];
user: UserWithSettings;
withExcludedAccounts?: boolean;
}): Promise<AccountBalancesResponse> {
const where: Prisma.AccountBalanceWhereInput = { userId: user.id };
const accountFilter = filters?.find(({ type }) => {
return type === 'ACCOUNT';
});
if (accountFilter) {
where.accountId = accountFilter.id;
}
if (withExcludedAccounts === false) {
where.Account = { isExcluded: false };
}
const balances = await this.prismaService.accountBalance.findMany({
where,
orderBy: {
date: 'asc'
},
select: {
Account: true,
date: true,
id: true,
value: true
},
where: {
accountId,
userId
}
});
return { balances };
return {
balances: balances.map((balance) => {
return {
...balance,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value,
balance.Account.currency,
user.Settings.settings.baseCurrency
)
};
})
};
}
}

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

@ -56,7 +56,13 @@ export class CoinGeckoService implements DataProviderInterface {
response.name = name;
} catch (error) {
Logger.error(error, 'CoinGeckoService');
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
}
Logger.error(message, 'CoinGeckoService');
}
return response;
@ -174,7 +180,13 @@ export class CoinGeckoService implements DataProviderInterface {
};
}
} catch (error) {
Logger.error(error, 'CoinGeckoService');
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
}
Logger.error(message, 'CoinGeckoService');
}
return response;
@ -216,7 +228,13 @@ export class CoinGeckoService implements DataProviderInterface {
};
});
} catch (error) {
Logger.error(error, 'CoinGeckoService');
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
}
Logger.error(message, 'CoinGeckoService');
}
return { items };

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

@ -346,7 +346,7 @@ export class DataProviderService {
);
try {
this.marketDataService.updateMany({
await this.marketDataService.updateMany({
data: Object.keys(response)
.filter((symbol) => {
return (

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

@ -229,7 +229,13 @@ export class EodHistoricalDataService implements DataProviderInterface {
return response;
} catch (error) {
Logger.error(error, 'EodHistoricalDataService');
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
}
Logger.error(message, 'EodHistoricalDataService');
}
return {};
@ -382,7 +388,13 @@ export class EodHistoricalDataService implements DataProviderInterface {
}
);
} catch (error) {
Logger.error(error, 'EodHistoricalDataService');
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
}
Logger.error(message, 'EodHistoricalDataService');
}
return searchResult;

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

@ -151,7 +151,13 @@ export class FinancialModelingPrepService implements DataProviderInterface {
};
}
} catch (error) {
Logger.error(error, 'FinancialModelingPrepService');
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
}
Logger.error(message, 'FinancialModelingPrepService');
}
return response;
@ -196,7 +202,13 @@ export class FinancialModelingPrepService implements DataProviderInterface {
};
});
} catch (error) {
Logger.error(error, 'FinancialModelingPrepService');
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
}
Logger.error(message, 'FinancialModelingPrepService');
}
return { items };

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

@ -163,7 +163,13 @@ export class RapidApiService implements DataProviderInterface {
return fgi;
} catch (error) {
Logger.error(error, 'RapidApiService');
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
}
Logger.error(message, 'RapidApiService');
return undefined;
}

7
apps/client/project.json

@ -152,8 +152,8 @@
"serve": {
"executor": "@nx/angular:webpack-dev-server",
"options": {
"browserTarget": "client:build",
"proxyConfig": "apps/client/proxy.conf.json"
"proxyConfig": "apps/client/proxy.conf.json",
"browserTarget": "client:build"
},
"configurations": {
"development-de": {
@ -215,8 +215,7 @@
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true
"jestConfig": "apps/client/jest.config.ts"
},
"outputs": ["{workspaceRoot}/coverage/apps/client"]
}

6
apps/client/src/app/adapter/custom-date-adapter.ts

@ -1,4 +1,3 @@
import { Platform } from '@angular/cdk/platform';
import { Inject, forwardRef } from '@angular/core';
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
import { getDateFormatString } from '@ghostfolio/common/helper';
@ -7,10 +6,9 @@ import { addYears, format, getYear, parse } from 'date-fns';
export class CustomDateAdapter extends NativeDateAdapter {
public constructor(
@Inject(MAT_DATE_LOCALE) public locale: string,
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,
platform: Platform
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string
) {
super(matDateLocale, platform);
super(matDateLocale);
}
/**

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

@ -29,14 +29,15 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./account-detail-dialog.component.scss']
})
export class AccountDetailDialog implements OnDestroy, OnInit {
public activities: OrderWithAccount[];
public balance: number;
public currency: string;
public equity: number;
public hasImpersonationId: boolean;
public historicalDataItems: HistoricalDataItem[];
public isLoadingActivities: boolean;
public isLoadingChart: boolean;
public name: string;
public orders: OrderWithAccount[];
public platformName: string;
public transactionCount: number;
public user: User;
@ -64,6 +65,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}
public ngOnInit() {
this.isLoadingActivities = true;
this.isLoadingChart = true;
this.dataService
@ -103,7 +105,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.orders = activities;
this.activities = activities;
this.isLoadingActivities = false;
this.changeDetectorRef.markForCheck();
});
@ -122,13 +126,13 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => {
this.historicalDataItems = chart.map(
({ date, value, valueInPercentage }) => {
({ date, netWorth, netWorthInPercentage }) => {
return {
date,
value:
this.hasImpersonationId || this.user.settings.isRestrictedView
? valueInPercentage
: value
? netWorthInPercentage
: netWorth
};
}
);
@ -153,8 +157,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
public onExport() {
this.dataService
.fetchExport(
this.orders.map((order) => {
return order.id;
this.activities.map(({ id }) => {
return id;
})
)
.pipe(takeUntil(this.unsubscribeSubject))

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

@ -31,7 +31,7 @@
></gf-investment-chart>
</div>
<div class="row">
<div class="mb-3 row">
<div class="col-6 mb-3">
<gf-value
i18n
@ -64,11 +64,15 @@
</div>
</div>
<div class="row" [ngClass]="{ 'd-none': !orders?.length }">
<div class="col mb-3">
<div class="h5 mb-0" i18n>Activities</div>
<mat-tab-group
animationDuration="0"
[mat-stretch-tabs]="false"
[ngClass]="{ 'd-none': isLoadingActivities }"
>
<mat-tab>
<ng-template i18n mat-tab-label>Activities</ng-template>
<gf-activities-table
[activities]="orders"
[activities]="activities"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
@ -79,8 +83,15 @@
[showActions]="false"
(export)="onExport()"
></gf-activities-table>
</div>
</div>
</mat-tab>
<mat-tab>
<ng-template i18n mat-tab-label>Cash Balances</ng-template>
<gf-account-balances
[accountId]="data.accountId"
[locale]="user?.settings?.locale"
></gf-account-balances>
</mat-tab>
</mat-tab-group>
</div>
</div>

4
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts

@ -2,9 +2,11 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTabsModule } from '@angular/material/tabs';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -15,6 +17,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
declarations: [AccountDetailDialog],
imports: [
CommonModule,
GfAccountBalancesModule,
GfActivitiesTableModule,
GfDialogFooterModule,
GfDialogHeaderModule,
@ -22,6 +25,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
GfValueModule,
MatButtonModule,
MatDialogModule,
MatTabsModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

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

@ -56,11 +56,11 @@
<a
href="https://twitter.com/ghostfolio_"
title="Post to Ghostfolio on X (formerly Twitter)"
>@ghostfolio_</a
>&#64;ghostfolio_</a
><ng-container *ngIf="user?.subscription?.type === 'Premium'"
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
>hi&#64;ghostfol.io</a
></ng-container
>
or start a discussion at

5
apps/client/src/app/pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.html

@ -131,8 +131,9 @@
</p>
<p>
Du erreichst mich per E-Mail unter
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> oder auf Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> oder auf
Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>.
</p>
<p>
Ich freue mich, von dir zu hören.<br />

4
apps/client/src/app/pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.html

@ -126,8 +126,8 @@
</p>
<p>
You can reach me by e-mail at
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>.
</p>
<p>
I look forward to hearing from you.<br />

6
apps/client/src/app/pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.html

@ -100,9 +100,9 @@
of users. In the future, I would like to involve more contributors
to further extend the functionality of Ghostfolio (e.g. with new
reports). Get in touch with me by e-mail at
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> if you
are interested, I’m happy to discuss ideas.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a> if
you are interested, I’m happy to discuss ideas.
</p>
<p>
I would like to say thank you for all your feedback and support

4
apps/client/src/app/pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.html

@ -90,8 +90,8 @@
<p>
If you would like to provide feedback or get involved in further
development of Ghostfolio, please get in touch by e-mail via
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>.
</p>
<p>
I look forward to hearing from you.<br />

6
apps/client/src/app/pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.html

@ -91,9 +91,9 @@
engineering to realize the full potential of open source software.
If you are a web developer and interested in personal finance,
please get in touch by e-mail via
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>. We are
happy to discuss ideas.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>. We
are happy to discuss ideas.
</p>
<p>
We would like to say thank you for all your feedback and support

4
apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.html

@ -85,8 +85,8 @@
>Slack</a
>
community or get in touch on Twitter
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or by
e-mail via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a> or by
e-mail via <a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a>.
</p>
<p>
We look forward to hearing from you.<br />

2
apps/client/src/app/pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.html

@ -92,7 +92,7 @@
>
community or via Twitter
<a href="https://twitter.com/ghostfolio_" target="_blank"
>@ghostfolio_</a
>&#64;ghostfolio_</a
>. We look forward to hearing from you!
</p>
</section>

2
apps/client/src/app/pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.html

@ -122,7 +122,7 @@
>Slack</a
>
community or connect with
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> on
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a> on
Twitter. We are happy to discuss ideas and get you involved.
</p>
<p>Thank you for all your feedback and support.</p>

2
apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html

@ -89,7 +89,7 @@
>Slack</a
>
community or get in touch on X
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>.
</p>
<p>
We look forward to hearing from you.<br />

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

@ -203,8 +203,8 @@
</mat-card-header>
<mat-card-content>
Please send an e-mail with the web address of your broker to
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> and we are happy to
add it.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> and we are
happy to add it.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
@ -234,11 +234,11 @@
<a
href="https://twitter.com/ghostfolio_"
title="Post to Ghostfolio on X (formerly Twitter)"
>@ghostfolio_</a
>&#64;ghostfolio_</a
><ng-container *ngIf="user?.subscription?.type === 'Premium'"
>,
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
>hi&#64;ghostfol.io</a
></ng-container
>
or
@ -263,11 +263,11 @@
<a
href="https://twitter.com/ghostfolio_"
title="Post to Ghostfolio on X (formerly Twitter)"
>@ghostfolio_</a
>&#64;ghostfolio_</a
><ng-container *ngIf="user?.subscription?.type === 'Premium'"
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
>hi&#64;ghostfol.io</a
></ng-container
>
or start a discussion at

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

@ -2,8 +2,9 @@
<div class="row">
<ul>
<li i18n="@@metaDescription">
Ghostfolio is a personal finance dashboard to keep track of your assets
like stocks, ETFs or cryptocurrencies across multiple platforms.
Ghostfolio is a personal finance dashboard to keep track of your net
worth including cash, stocks, ETFs and cryptocurrencies across multiple
platforms.
</li>
<li i18n="@@metaKeywords">
app, asset, cryptocurrency, dashboard, etf, finance, management,

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

@ -1,5 +1,5 @@
<div class="container">
<div class="row mb-3">
<div class="mb-3 row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Activities</h1>
<gf-activities-table

2
apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.html

@ -33,7 +33,7 @@
place. If I lose it, I cannot get my account back.
</p>
</div>
<div class="float-right" mat-dialog-actions>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-flat-button [mat-dialog-close]="undefined">Cancel</button>
<button
color="primary"

11
apps/client/src/app/pages/resources/personal-finance-tools/product-page-template.html

@ -181,13 +181,14 @@
</tr>
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Pricing</td>
<td class="mat-mdc-cell px-1 py-2" i18n>
Starting from {{ product1.pricingPerYear }} / year
<td class="mat-mdc-cell px-1 py-2">
<span i18n>Starting from</span> ${{ price }} /
<span i18n>year</span>
</td>
<td class="mat-mdc-cell px-1 py-2">
<ng-container *ngIf="product2.pricingPerYear" i18n
>Starting from {{ product2.pricingPerYear }} /
year</ng-container
<ng-container *ngIf="product2.pricingPerYear"
><span i18n>Starting from</span> {{ product2.pricingPerYear
}} / <span i18n>year</span></ng-container
>
</td>
</tr>

1
apps/client/src/app/pages/resources/personal-finance-tools/products.ts

@ -67,7 +67,6 @@ export const products: Product[] = [
],
name: 'Ghostfolio',
origin: $localize`Switzerland`,
pricingPerYear: '$24',
region: $localize`Global`,
slogan: 'Open Source Wealth Management',
useAnonymously: true

3
apps/client/src/app/pages/resources/personal-finance-tools/products/allvue-systems-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class AllvueSystemsPageComponent {
export class AllvueSystemsPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/altoo-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class AltooPageComponent {
export class AltooPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

18
apps/client/src/app/pages/resources/personal-finance-tools/products/base-page.component.ts

@ -0,0 +1,18 @@
import { Component, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
@Component({
selector: 'gf-base-product-page',
template: ''
})
export class BaseProductPageComponent implements OnInit {
public price: number;
public constructor(private dataService: DataService) {}
public ngOnInit() {
const { subscriptions } = this.dataService.fetchInfo();
this.price = subscriptions?.default?.price;
}
}

3
apps/client/src/app/pages/resources/personal-finance-tools/products/basil-finance-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class BasilFinancePageComponent {
export class BasilFinancePageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/beanvest-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class BeanvestPageComponent {
export class BeanvestPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/capitally-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class CapitallyPageComponent {
export class CapitallyPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/capmon-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class CapMonPageComponent {
export class CapMonPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/compound-planning-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class CompoundPlanningPageComponent {
export class CompoundPlanningPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/copilot-money-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class CopilotMoneyPageComponent {
export class CopilotMoneyPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/de.fi-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class DeFiPageComponent {
export class DeFiPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/delta-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class DeltaPageComponent {
export class DeltaPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/divvydiary-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class DivvyDiaryPageComponent {
export class DivvyDiaryPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/eightfigures-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class EightFiguresPageComponent {
export class EightFiguresPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/empower-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class EmpowerPageComponent {
export class EmpowerPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/exirio-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class ExirioPageComponent {
export class ExirioPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/finary-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class FinaryPageComponent {
export class FinaryPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/finwise-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class FinWisePageComponent {
export class FinWisePageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/folishare-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class FolisharePageComponent {
export class FolisharePageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/getquin-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class GetquinPageComponent {
export class GetquinPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/gospatz-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class GoSpatzPageComponent {
export class GoSpatzPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/intuit-mint-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class IntuitMintPageComponent {
export class IntuitMintPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/justetf-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class JustEtfPageComponent {
export class JustEtfPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/kubera-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class KuberaPageComponent {
export class KuberaPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/magnifi-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class MagnifiPageComponent {
export class MagnifiPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/markets.sh-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class MarketsShPageComponent {
export class MarketsShPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/maybe-finance-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class MaybeFinancePageComponent {
export class MaybeFinancePageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/monarch-money-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class MonarchMoneyPageComponent {
export class MonarchMoneyPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/monse-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class MonsePageComponent {
export class MonsePageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/parqet-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class ParqetPageComponent {
export class ParqetPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/plannix-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class PlannixPageComponent {
export class PlannixPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/portfolio-dividend-tracker-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class PortfolioDividendTrackerPageComponent {
export class PortfolioDividendTrackerPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/portseido-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class PortseidoPageComponent {
export class PortseidoPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/projectionlab-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class ProjectionLabPageComponent {
export class ProjectionLabPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/rocket-money-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class RocketMoneyPageComponent {
export class RocketMoneyPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/seeking-alpha-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class SeekingAlphaPageComponent {
export class SeekingAlphaPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/sharesight-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class SharesightPageComponent {
export class SharesightPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/simple-portfolio-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class SimplePortfolioPageComponent {
export class SimplePortfolioPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/snowball-analytics-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class SnowballAnalyticsPageComponent {
export class SnowballAnalyticsPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/stockle-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class StocklePageComponent {
export class StocklePageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/stockmarketeye-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class StockMarketEyePageComponent {
export class StockMarketEyePageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/sumio-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class SumioPageComponent {
export class SumioPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/tiller-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class TillerPageComponent {
export class TillerPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/utluna-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class UtlunaPageComponent {
export class UtlunaPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/vyzer-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class VyzerPageComponent {
export class VyzerPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/wealthica-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class WealthicaPageComponent {
export class WealthicaPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/whal-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class WhalPageComponent {
export class WhalPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/yeekatee-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class YeekateePageComponent {
export class YeekateePageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

3
apps/client/src/app/pages/resources/personal-finance-tools/products/ynab-page.component.ts

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
@ -13,7 +14,7 @@ import { products } from '../products';
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class YnabPageComponent {
export class YnabPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});

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

@ -18,6 +18,7 @@ import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
Access,
AccountBalancesResponse,
Accounts,
BenchmarkMarketDataDetails,
BenchmarkResponse,
@ -137,6 +138,12 @@ export class DataService {
return this.http.get<AccountWithValue>(`/api/v1/account/${aAccountId}`);
}
public fetchAccountBalances(aAccountId: string) {
return this.http.get<AccountBalancesResponse>(
`/api/v1/account/${aAccountId}/balances`
);
}
public fetchAccounts() {
return this.http.get<Accounts>('/api/v1/account');
}

13
apps/client/src/assets/oss-friends.json

@ -1,5 +1,5 @@
{
"createdAt": "2023-11-17T00:00:00.000Z",
"createdAt": "2023-11-30T00:00:00.000Z",
"data": [
{
"name": "BoxyHQ",
@ -16,6 +16,11 @@
"description": "Centralize community, product, and customer data to understand which companies are engaging with your open source project.",
"href": "https://www.crowd.dev"
},
{
"name": "DevHunt",
"description": "Find the best Dev Tools upvoted by the community every week.",
"href": "https://devhunt.org"
},
{
"name": "Documenso",
"description": "The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.",
@ -59,7 +64,7 @@
{
"name": "Hook0",
"description": "Open-Source Webhooks-as-a-service (WaaS) that makes it easy for developers to send webhooks.",
"href": "https://www.hook0.com/"
"href": "https://www.hook0.com"
},
{
"name": "HTMX",
@ -89,7 +94,7 @@
{
"name": "Papermark",
"description": "Open-Source Docsend Alternative to securely share documents with real-time analytics.",
"href": "https://www.papermark.io/"
"href": "https://www.papermark.io"
},
{
"name": "Requestly",
@ -109,7 +114,7 @@
{
"name": "Shelf.nu",
"description": "Open Source Asset and Equipment tracking software that lets you create QR asset labels, manage and overview your assets across locations.",
"href": "https://www.shelf.nu/"
"href": "https://www.shelf.nu"
},
{
"name": "Sniffnet",

1080
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

1080
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

1080
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

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

File diff suppressed because it is too large

1080
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

1080
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

1080
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

1048
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

1046
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

3
libs/common/project.json

@ -14,8 +14,7 @@
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/libs/common"],
"options": {
"jestConfig": "libs/common/jest.config.ts",
"passWithNoTests": true
"jestConfig": "libs/common/jest.config.ts"
}
}
},

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

Loading…
Cancel
Save