Compare commits
40 Commits
Author | SHA1 | Date | |
---|---|---|---|
2c7ece50fe | |||
51a0ede3e4 | |||
531964636b | |||
e461fff1d7 | |||
4f9a5f0340 | |||
8d80e840b8 | |||
833982a9de | |||
c85966e5ed | |||
43f67ba832 | |||
cbea8ac9d3 | |||
d4c939e41d | |||
c1f129501a | |||
377ba75e4c | |||
77b13b88f0 | |||
813e73a0a3 | |||
1d796a9597 | |||
4eedf64a3c | |||
ed4dd79c72 | |||
6f4fd0826c | |||
8e3a144a37 | |||
07b0a2c40a | |||
c5dc3d4272 | |||
73e69273b4 | |||
e0b74ef418 | |||
2b491dc732 | |||
79fc22b5ae | |||
0a83bcd697 | |||
52540d460b | |||
6ff2e0f952 | |||
b3e72383bc | |||
bdfba4d509 | |||
8a411b707d | |||
e21601202e | |||
8f66040df1 | |||
5ad248a643 | |||
fa36c42af4 | |||
d4ddc781e1 | |||
386dd56590 | |||
f28b13604a | |||
d827858d0b |
7
.github/workflows/build-code.yml
vendored
7
.github/workflows/build-code.yml
vendored
@ -4,6 +4,9 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@ -13,12 +16,12 @@ jobs:
|
||||
- 18
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js ${{ matrix.node_version }}
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node_version }}
|
||||
cache: 'yarn'
|
||||
|
2
.github/workflows/docker-image.yml
vendored
2
.github/workflows/docker-image.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
|
63
CHANGELOG.md
63
CHANGELOG.md
@ -5,7 +5,66 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 2.25.0 - 2023-11-19
|
||||
## 2.29.0 - 2023-12-09
|
||||
|
||||
### Added
|
||||
|
||||
- Introduced a lazy-loaded activities table on the portfolio activities page (experimental)
|
||||
|
||||
### Changed
|
||||
|
||||
- Set the actions columns of various tables to stick at the end
|
||||
- Increased the height of the tabs on mobile
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Türkçe (`tr`)
|
||||
- Upgraded `marked` from version `4.2.12` to `9.1.6`
|
||||
- Upgraded `ngx-markdown` from version `15.1.0` to `17.1.1`
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.8.3` to `2.9.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the biometric authentication registration
|
||||
|
||||
## 2.28.0 - 2023-12-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added a historical cash balances table to the account detail dialog
|
||||
- Introduced a `HasPermission` annotation for endpoints
|
||||
|
||||
### Changed
|
||||
|
||||
- Relaxed the check for duplicates in the preview step of the activities import (allow same day)
|
||||
- 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`
|
||||
- Upgraded `yahoo-finance2` from version `2.8.1` to `2.9.0`
|
||||
|
||||
## 2.25.1 - 2023-11-19
|
||||
|
||||
### Added
|
||||
|
||||
@ -132,7 +191,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the check for duplicates in the preview step of the activities import (allow different accounts)
|
||||
- Relaxed the check for duplicates in the preview step of the activities import (allow different accounts)
|
||||
- Improved the usability and validation in the cash balance transfer from one to another account
|
||||
- Changed the checkboxes to slide toggles in the overview of the admin control panel
|
||||
- Switched from the deprecated (`PUT`) to the new endpoint (`POST`) to manage historical market data in the asset profile details dialog of the admin control panel
|
||||
|
@ -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
|
||||
|
||||
|
@ -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).
|
||||
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -17,7 +17,6 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { Access as AccessModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccessModule } from './access.module';
|
||||
import { AccessService } from './access.service';
|
||||
import { CreateAccessDto } from './create-access.dto';
|
||||
|
||||
@ -83,7 +82,7 @@ export class AccessController {
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
|
||||
const access = await this.accessService.access({ id });
|
||||
|
||||
if (
|
||||
|
@ -0,0 +1,51 @@
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AccountBalanceService } from './account-balance.service';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { AccountBalance } from '@prisma/client';
|
||||
|
||||
@Controller('account-balance')
|
||||
export class AccountBalanceController {
|
||||
public constructor(
|
||||
private readonly accountBalanceService: AccountBalanceService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAccountBalance(
|
||||
@Param('id') id: string
|
||||
): Promise<AccountBalance> {
|
||||
const accountBalance = await this.accountBalanceService.accountBalance({
|
||||
id
|
||||
});
|
||||
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.deleteAccountBalance
|
||||
) ||
|
||||
!accountBalance ||
|
||||
accountBalance.userId !== this.request.user.id
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accountBalanceService.deleteAccountBalance({
|
||||
id
|
||||
});
|
||||
}
|
||||
}
|
14
apps/api/src/app/account-balance/account-balance.module.ts
Normal file
14
apps/api/src/app/account-balance/account-balance.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
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';
|
||||
|
||||
import { AccountBalanceController } from './account-balance.controller';
|
||||
import { AccountBalanceService } from './account-balance.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AccountBalanceController],
|
||||
exports: [AccountBalanceService],
|
||||
imports: [ExchangeRateDataModule, PrismaModule],
|
||||
providers: [AccountBalanceService]
|
||||
})
|
||||
export class AccountBalanceModule {}
|
91
apps/api/src/app/account-balance/account-balance.service.ts
Normal file
91
apps/api/src/app/account-balance/account-balance.service.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
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 exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async accountBalance(
|
||||
accountBalanceWhereInput: Prisma.AccountBalanceWhereInput
|
||||
): Promise<AccountBalance | null> {
|
||||
return this.prismaService.accountBalance.findFirst({
|
||||
include: {
|
||||
Account: true
|
||||
},
|
||||
where: accountBalanceWhereInput
|
||||
});
|
||||
}
|
||||
|
||||
public async createAccountBalance(
|
||||
data: Prisma.AccountBalanceCreateInput
|
||||
): Promise<AccountBalance> {
|
||||
return this.prismaService.accountBalance.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteAccountBalance(
|
||||
where: Prisma.AccountBalanceWhereUniqueInput
|
||||
): Promise<AccountBalance> {
|
||||
return this.prismaService.accountBalance.delete({
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async getAccountBalances({
|
||||
filters,
|
||||
user,
|
||||
withExcludedAccounts
|
||||
}: {
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
balances: balances.map((balance) => {
|
||||
return {
|
||||
...balance,
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
balance.value,
|
||||
balance.Account.currency,
|
||||
user.Settings.settings.baseCurrency
|
||||
)
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import {
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
|
@ -26,7 +26,7 @@ import {
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, format, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@ -83,12 +83,13 @@ export class ImportService {
|
||||
|
||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||
|
||||
const date = parseDate(dateString);
|
||||
const isDuplicate = orders.some((activity) => {
|
||||
return (
|
||||
activity.accountId === Account?.id &&
|
||||
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||
isSameDay(activity.date, parseDate(dateString)) &&
|
||||
isSameSecond(activity.date, date) &&
|
||||
activity.quantity === quantity &&
|
||||
activity.SymbolProfile.symbol === assetProfile.symbol &&
|
||||
activity.type === 'DIVIDEND' &&
|
||||
@ -102,6 +103,7 @@ export class ImportService {
|
||||
|
||||
return {
|
||||
Account,
|
||||
date,
|
||||
error,
|
||||
quantity,
|
||||
value,
|
||||
@ -109,7 +111,6 @@ export class ImportService {
|
||||
accountUserId: undefined,
|
||||
comment: undefined,
|
||||
createdAt: undefined,
|
||||
date: parseDate(dateString),
|
||||
fee: 0,
|
||||
feeInBaseCurrency: 0,
|
||||
id: assetProfile.id,
|
||||
@ -482,13 +483,13 @@ export class ImportService {
|
||||
type,
|
||||
unitPrice
|
||||
}) => {
|
||||
const date = parseISO(<string>(<unknown>dateString));
|
||||
const date = parseISO(dateString);
|
||||
const isDuplicate = existingActivities.some((activity) => {
|
||||
return (
|
||||
activity.accountId === accountId &&
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
activity.SymbolProfile.dataSource === dataSource &&
|
||||
isSameDay(activity.date, date) &&
|
||||
isSameSecond(activity.date, date) &&
|
||||
activity.fee === fee &&
|
||||
activity.quantity === quantity &&
|
||||
activity.SymbolProfile.symbol === symbol &&
|
||||
|
@ -2,6 +2,7 @@ import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
|
||||
export interface Activities {
|
||||
activities: Activity[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface Activity extends OrderWithAccount {
|
||||
|
@ -24,7 +24,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Order as OrderModel } from '@prisma/client';
|
||||
import { Order as OrderModel, Prisma } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@ -90,6 +90,8 @@ export class OrderController {
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('skip') skip?: number,
|
||||
@Query('sortColumn') sortColumn?: string,
|
||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('take') take?: number
|
||||
): Promise<Activities> {
|
||||
@ -103,8 +105,10 @@ export class OrderController {
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
const activities = await this.orderService.getOrders({
|
||||
const { activities, count } = await this.orderService.getOrders({
|
||||
filters,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
skip: isNaN(skip) ? undefined : skip,
|
||||
@ -113,7 +117,7 @@ export class OrderController {
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
return { activities };
|
||||
return { activities, count };
|
||||
}
|
||||
|
||||
@Post()
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
|
@ -25,7 +25,7 @@ import { endOfToday, isAfter } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { Activity } from './interfaces/activities.interface';
|
||||
import { Activities, Activity } from './interfaces/activities.interface';
|
||||
|
||||
@Injectable()
|
||||
export class OrderService {
|
||||
@ -51,7 +51,7 @@ export class OrderService {
|
||||
take?: number;
|
||||
cursor?: Prisma.OrderWhereUniqueInput;
|
||||
where?: Prisma.OrderWhereInput;
|
||||
orderBy?: Prisma.OrderOrderByWithRelationInput;
|
||||
orderBy?: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput>;
|
||||
}): Promise<OrderWithAccount[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
@ -231,6 +231,8 @@ export class OrderService {
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
skip,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
take = Number.MAX_SAFE_INTEGER,
|
||||
types,
|
||||
userCurrency,
|
||||
@ -240,12 +242,17 @@ export class OrderService {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
skip?: number;
|
||||
sortColumn?: string;
|
||||
sortDirection?: Prisma.SortOrder;
|
||||
take?: number;
|
||||
types?: TypeOfOrder[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<Activity[]> {
|
||||
}): Promise<Activities> {
|
||||
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
|
||||
{ date: 'asc' }
|
||||
];
|
||||
const where: Prisma.OrderWhereInput = { userId };
|
||||
|
||||
const {
|
||||
@ -307,6 +314,10 @@ export class OrderService {
|
||||
};
|
||||
}
|
||||
|
||||
if (sortColumn) {
|
||||
orderBy = [{ [sortColumn]: sortDirection }];
|
||||
}
|
||||
|
||||
if (types) {
|
||||
where.OR = types.map((type) => {
|
||||
return {
|
||||
@ -317,8 +328,9 @@ export class OrderService {
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
await this.orders({
|
||||
const [orders, count] = await Promise.all([
|
||||
this.orders({
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
@ -332,10 +344,12 @@ export class OrderService {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
SymbolProfile: true,
|
||||
tags: true
|
||||
},
|
||||
orderBy: { date: 'asc' }
|
||||
})
|
||||
)
|
||||
}
|
||||
}),
|
||||
this.prismaService.order.count({ where })
|
||||
]);
|
||||
|
||||
const activities = orders
|
||||
.filter((order) => {
|
||||
return (
|
||||
withExcludedAccounts ||
|
||||
@ -361,6 +375,8 @@ export class OrderService {
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
return { activities, count };
|
||||
}
|
||||
|
||||
public async updateOrder({
|
||||
|
@ -346,16 +346,34 @@ 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)
|
||||
.div(performanceInformation.performance.totalInvestment)
|
||||
.toNumber(),
|
||||
valueInPercentage: new Big(value)
|
||||
.div(performanceInformation.performance.currentValue)
|
||||
.toNumber()
|
||||
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:
|
||||
performanceInformation.performance.currentValue === 0
|
||||
? 0
|
||||
: new Big(value)
|
||||
.div(performanceInformation.performance.currentValue)
|
||||
.toNumber()
|
||||
};
|
||||
}
|
||||
);
|
||||
@ -365,6 +383,7 @@ export class PortfolioController {
|
||||
[
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentNetWorth',
|
||||
'currentValue',
|
||||
'totalInvestment'
|
||||
]
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
@ -67,14 +68,16 @@ import {
|
||||
isBefore,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
isValid,
|
||||
max,
|
||||
min,
|
||||
parseISO,
|
||||
set,
|
||||
setDayOfYear,
|
||||
subDays,
|
||||
subYears
|
||||
} from 'date-fns';
|
||||
import { isEmpty, sortBy, uniq, uniqBy } from 'lodash';
|
||||
import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash';
|
||||
|
||||
import {
|
||||
HistoricalDataContainer,
|
||||
@ -91,6 +94,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,
|
||||
@ -114,8 +118,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([
|
||||
@ -217,7 +225,7 @@ export class PortfolioService {
|
||||
}): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const activities = await this.orderService.getOrders({
|
||||
const { activities } = await this.orderService.getOrders({
|
||||
filters,
|
||||
userId,
|
||||
types: ['DIVIDEND'],
|
||||
@ -267,6 +275,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,
|
||||
@ -274,12 +289,6 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
if (transactionPoints.length === 0) {
|
||||
return {
|
||||
investments: [],
|
||||
streaks: { currentStreak: 0, longestStreak: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
let investments: InvestmentItem[];
|
||||
|
||||
@ -367,67 +376,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,
|
||||
@ -731,13 +679,13 @@ export class PortfolioService {
|
||||
const user = await this.userService.user({ id: userId });
|
||||
const userCurrency = this.getUserCurrency(user);
|
||||
|
||||
const orders = (
|
||||
await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts: true
|
||||
})
|
||||
).filter(({ SymbolProfile }) => {
|
||||
const { activities } = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
const orders = activities.filter(({ SymbolProfile }) => {
|
||||
return (
|
||||
SymbolProfile.dataSource === aDataSource &&
|
||||
SymbolProfile.symbol === aSymbol
|
||||
@ -1028,12 +976,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,
|
||||
@ -1041,6 +983,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);
|
||||
@ -1126,6 +1074,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,
|
||||
@ -1139,7 +1112,7 @@ export class PortfolioService {
|
||||
orders: portfolioOrders
|
||||
});
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
if (accountBalanceItems?.length <= 0 && transactionPoints?.length <= 0) {
|
||||
return {
|
||||
chart: [],
|
||||
firstOrderDate: undefined,
|
||||
@ -1149,6 +1122,7 @@ export class PortfolioService {
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentNetPerformance: 0,
|
||||
currentNetPerformancePercent: 0,
|
||||
currentNetWorth: 0,
|
||||
currentValue: 0,
|
||||
totalInvestment: 0
|
||||
}
|
||||
@ -1157,7 +1131,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,
|
||||
@ -1175,17 +1157,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) {
|
||||
@ -1195,34 +1177,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()
|
||||
}
|
||||
};
|
||||
@ -1376,6 +1366,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
|
||||
@ -1593,18 +1639,18 @@ export class PortfolioService {
|
||||
userId
|
||||
});
|
||||
|
||||
const activities = await this.orderService.getOrders({
|
||||
const { activities } = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
|
||||
const excludedActivities = (
|
||||
await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts: true
|
||||
})
|
||||
).filter(({ Account: account }) => {
|
||||
let { activities: excludedActivities } = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
excludedActivities = excludedActivities.filter(({ Account: account }) => {
|
||||
return account?.isExcluded ?? false;
|
||||
});
|
||||
|
||||
@ -1784,7 +1830,7 @@ export class PortfolioService {
|
||||
const userCurrency =
|
||||
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
|
||||
|
||||
const orders = await this.orderService.getOrders({
|
||||
const { activities, count } = await this.orderService.getOrders({
|
||||
filters,
|
||||
includeDrafts,
|
||||
userCurrency,
|
||||
@ -1793,11 +1839,11 @@ export class PortfolioService {
|
||||
types: ['BUY', 'SELL']
|
||||
});
|
||||
|
||||
if (orders.length <= 0) {
|
||||
if (count <= 0) {
|
||||
return { transactionPoints: [], orders: [], portfolioOrders: [] };
|
||||
}
|
||||
|
||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||
const portfolioOrders: PortfolioOrder[] = activities.map((order) => ({
|
||||
currency: order.SymbolProfile.currency,
|
||||
dataSource: order.SymbolProfile.dataSource,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
@ -1831,8 +1877,8 @@ export class PortfolioService {
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
return {
|
||||
orders,
|
||||
portfolioOrders,
|
||||
orders: activities,
|
||||
transactionPoints: portfolioCalculator.getTransactionPoints()
|
||||
};
|
||||
}
|
||||
@ -1867,13 +1913,14 @@ export class PortfolioService {
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}) {
|
||||
const ordersOfTypeItemOrLiability = await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts,
|
||||
types: ['ITEM', 'LIABILITY']
|
||||
});
|
||||
const { activities: ordersOfTypeItemOrLiability } =
|
||||
await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts,
|
||||
types: ['ITEM', 'LIABILITY']
|
||||
});
|
||||
|
||||
const accounts: PortfolioDetails['accounts'] = {};
|
||||
const platforms: PortfolioDetails['platforms'] = {};
|
||||
@ -1999,4 +2046,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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
@ -198,16 +198,18 @@ export class UserService {
|
||||
new Date(),
|
||||
user.createdAt
|
||||
);
|
||||
let frequency = 20;
|
||||
let frequency = 15;
|
||||
|
||||
if (daysSinceRegistration > 180) {
|
||||
if (daysSinceRegistration > 365) {
|
||||
frequency = 2;
|
||||
} else if (daysSinceRegistration > 180) {
|
||||
frequency = 3;
|
||||
} else if (daysSinceRegistration > 60) {
|
||||
frequency = 5;
|
||||
} else if (daysSinceRegistration > 30) {
|
||||
frequency = 10;
|
||||
frequency = 8;
|
||||
} else if (daysSinceRegistration > 15) {
|
||||
frequency = 15;
|
||||
frequency = 12;
|
||||
}
|
||||
|
||||
if (Analytics?.activityCount % frequency === 1) {
|
||||
|
@ -82,10 +82,18 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-compound-planning</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-de.fi</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -94,6 +102,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-empower</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -202,6 +214,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-tiller</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -214,6 +230,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-whal</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -396,10 +416,18 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-compound-planning</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-de.fi</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -408,6 +436,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-empower</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -516,6 +548,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-tiller</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -528,6 +564,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-whal</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -730,10 +770,18 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capmon</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-compound-planning</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-de.fi</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -742,6 +790,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-empower</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -850,6 +902,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-tiller</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -862,6 +918,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-whal</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -910,10 +970,18 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-compound-planning</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-de.fi</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -922,6 +990,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-empower</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -1030,6 +1102,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-tiller</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -1042,6 +1118,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-whal</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
|
6
apps/api/src/decorators/has-permission.decorator.ts
Normal file
6
apps/api/src/decorators/has-permission.decorator.ts
Normal file
@ -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
Normal file
55
apps/api/src/guards/has-permission.guard.spec.ts
Normal file
@ -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
Normal file
37
apps/api/src/guards/has-permission.guard.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
@ -77,7 +77,7 @@ const locales = {
|
||||
title: `Hacktoberfest 2023 - ${title}`
|
||||
},
|
||||
'/en/blog/2023/11/black-week-2023': {
|
||||
featureGraphicPath: 'assets/images/blog/black-week-2023.png',
|
||||
featureGraphicPath: 'assets/images/blog/black-week-2023.jpg',
|
||||
title: `Black Week 2023 - ${title}`
|
||||
},
|
||||
'/en/blog/2023/11/hacktoberfest-2023-debriefing': {
|
||||
@ -91,6 +91,9 @@ const isFileRequest = (filename: string) => {
|
||||
return true;
|
||||
} else if (
|
||||
filename.includes('auth/ey') ||
|
||||
filename.includes(
|
||||
'personal-finance-tools/open-source-alternative-to-de.fi'
|
||||
) ||
|
||||
filename.includes(
|
||||
'personal-finance-tools/open-source-alternative-to-markets.sh'
|
||||
)
|
||||
|
@ -1,10 +0,0 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({
|
||||
exports: [AccountBalanceService],
|
||||
imports: [PrismaModule],
|
||||
providers: [AccountBalanceService]
|
||||
})
|
||||
export class AccountBalanceModule {}
|
@ -1,42 +0,0 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { AccountBalancesResponse } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AccountBalance, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AccountBalanceService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async createAccountBalance(
|
||||
data: Prisma.AccountBalanceCreateInput
|
||||
): Promise<AccountBalance> {
|
||||
return this.prismaService.accountBalance.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async getAccountBalances({
|
||||
accountId,
|
||||
userId
|
||||
}: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}): Promise<AccountBalancesResponse> {
|
||||
const balances = await this.prismaService.accountBalance.findMany({
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
select: {
|
||||
date: true,
|
||||
id: true,
|
||||
value: true
|
||||
},
|
||||
where: {
|
||||
accountId,
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
return { balances };
|
||||
}
|
||||
}
|
@ -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 };
|
||||
|
@ -346,7 +346,7 @@ export class DataProviderService {
|
||||
);
|
||||
|
||||
try {
|
||||
this.marketDataService.updateMany({
|
||||
await this.marketDataService.updateMany({
|
||||
data: Object.keys(response)
|
||||
.filter((symbol) => {
|
||||
return (
|
||||
|
@ -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;
|
||||
|
@ -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 };
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -37,7 +37,7 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<ng-container matColumnDef="actions" stickyEnd>
|
||||
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
|
||||
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
|
@ -11,7 +11,11 @@ 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 { downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import { HistoricalDataItem, User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AccountBalancesResponse,
|
||||
HistoricalDataItem,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import Big from 'big.js';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
@ -20,6 +24,7 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { AccountDetailDialogParams } from './interfaces/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
@Component({
|
||||
host: { class: 'd-flex flex-column h-100' },
|
||||
@ -29,14 +34,17 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
|
||||
styleUrls: ['./account-detail-dialog.component.scss']
|
||||
})
|
||||
export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
public accountBalances: AccountBalancesResponse['balances'];
|
||||
public activities: OrderWithAccount[];
|
||||
public balance: number;
|
||||
public currency: string;
|
||||
public equity: number;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToDeleteAccountBalance: 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;
|
||||
@ -58,13 +66,18 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.hasPermissionToDeleteAccountBalance = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.deleteAccountBalance
|
||||
);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnInit() {
|
||||
this.isLoadingChart = true;
|
||||
this.isLoadingActivities = true;
|
||||
|
||||
this.dataService
|
||||
.fetchAccount(this.data.accountId)
|
||||
@ -103,37 +116,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ activities }) => {
|
||||
this.orders = activities;
|
||||
this.activities = activities;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioPerformance({
|
||||
filters: [
|
||||
{
|
||||
id: this.data.accountId,
|
||||
type: 'ACCOUNT'
|
||||
}
|
||||
],
|
||||
range: 'max',
|
||||
withExcludedAccounts: true
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ chart }) => {
|
||||
this.historicalDataItems = chart.map(
|
||||
({ date, value, valueInPercentage }) => {
|
||||
return {
|
||||
date,
|
||||
value:
|
||||
this.hasImpersonationId || this.user.settings.isRestrictedView
|
||||
? valueInPercentage
|
||||
: value
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
this.isLoadingChart = false;
|
||||
this.isLoadingActivities = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
@ -144,17 +129,32 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
.subscribe((impersonationId) => {
|
||||
this.hasImpersonationId = !!impersonationId;
|
||||
});
|
||||
|
||||
this.fetchAccountBalances();
|
||||
this.fetchPortfolioPerformance();
|
||||
}
|
||||
|
||||
public onClose() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onDeleteAccountBalance(aId: string) {
|
||||
this.dataService
|
||||
.deleteAccountBalance(aId)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.fetchAccountBalances();
|
||||
this.fetchPortfolioPerformance();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public onExport() {
|
||||
this.dataService
|
||||
.fetchExport(
|
||||
this.orders.map((order) => {
|
||||
return order.id;
|
||||
this.activities.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -172,6 +172,51 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private fetchAccountBalances() {
|
||||
this.dataService
|
||||
.fetchAccountBalances(this.data.accountId)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ balances }) => {
|
||||
this.accountBalances = balances;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
private fetchPortfolioPerformance() {
|
||||
this.isLoadingChart = true;
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioPerformance({
|
||||
filters: [
|
||||
{
|
||||
id: this.data.accountId,
|
||||
type: 'ACCOUNT'
|
||||
}
|
||||
],
|
||||
range: 'max',
|
||||
withExcludedAccounts: true
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ chart }) => {
|
||||
this.historicalDataItems = chart.map(
|
||||
({ date, netWorth, netWorthInPercentage }) => {
|
||||
return {
|
||||
date,
|
||||
value:
|
||||
this.hasImpersonationId || this.user.settings.isRestrictedView
|
||||
? netWorthInPercentage
|
||||
: netWorth
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
this.isLoadingChart = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
|
@ -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,18 @@
|
||||
[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
|
||||
[accountBalances]="accountBalances"
|
||||
[accountId]="data.accountId"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccountBalance && !user.settings.isRestrictedView"
|
||||
(accountBalanceDeleted)="onDeleteAccountBalance($event)"
|
||||
></gf-account-balances>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -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]
|
||||
|
@ -241,7 +241,7 @@
|
||||
></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<ng-container matColumnDef="actions" stickyEnd>
|
||||
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
<button
|
||||
|
@ -120,7 +120,7 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<ng-container matColumnDef="actions" stickyEnd>
|
||||
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
|
@ -129,7 +129,7 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<ng-container matColumnDef="actions" stickyEnd>
|
||||
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
|
@ -68,7 +68,7 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<ng-container matColumnDef="actions" stickyEnd>
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="px-1 text-center"
|
||||
|
@ -48,7 +48,7 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<ng-container matColumnDef="actions" stickyEnd>
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="px-1 text-center"
|
||||
|
@ -178,7 +178,7 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<ng-container matColumnDef="actions" stickyEnd>
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="mat-mdc-header-cell px-1 py-2"
|
||||
|
@ -15,7 +15,7 @@ import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import {
|
||||
STAY_SIGNED_IN,
|
||||
KEY_STAY_SIGNED_IN,
|
||||
SettingsStorageService
|
||||
} from '@ghostfolio/client/services/settings-storage.service';
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
@ -196,7 +196,7 @@ export class HeaderComponent implements OnChanges {
|
||||
public setToken(aToken: string) {
|
||||
this.tokenStorageService.saveToken(
|
||||
aToken,
|
||||
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
|
||||
this.settingsStorageService.getSetting(KEY_STAY_SIGNED_IN) === 'true'
|
||||
);
|
||||
|
||||
this.router.navigate(['/']);
|
||||
|
@ -4,7 +4,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { Router } from '@angular/router';
|
||||
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
|
||||
import {
|
||||
STAY_SIGNED_IN,
|
||||
KEY_STAY_SIGNED_IN,
|
||||
SettingsStorageService
|
||||
} from '@ghostfolio/client/services/settings-storage.service';
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
@ -31,7 +31,7 @@ export class LoginWithAccessTokenDialog {
|
||||
|
||||
public onChangeStaySignedIn(aValue: MatCheckboxChange) {
|
||||
this.settingsStorageService.setSetting(
|
||||
STAY_SIGNED_IN,
|
||||
KEY_STAY_SIGNED_IN,
|
||||
aValue.checked?.toString()
|
||||
);
|
||||
}
|
||||
|
@ -8,7 +8,8 @@ import {
|
||||
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import {
|
||||
STAY_SIGNED_IN,
|
||||
KEY_STAY_SIGNED_IN,
|
||||
KEY_TOKEN,
|
||||
SettingsStorageService
|
||||
} from '@ghostfolio/client/services/settings-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
@ -241,7 +242,8 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.settingsStorageService.removeSetting(STAY_SIGNED_IN);
|
||||
this.settingsStorageService.removeSetting(KEY_STAY_SIGNED_IN);
|
||||
this.settingsStorageService.removeSetting(KEY_TOKEN);
|
||||
|
||||
this.update();
|
||||
});
|
||||
|
@ -56,11 +56,11 @@
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>@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@ghostfol.io</a
|
||||
></ng-container
|
||||
>
|
||||
or start a discussion at
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
STAY_SIGNED_IN,
|
||||
KEY_STAY_SIGNED_IN,
|
||||
SettingsStorageService
|
||||
} from '@ghostfolio/client/services/settings-storage.service';
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
@ -31,7 +31,7 @@ export class AuthPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.tokenStorageService.saveToken(
|
||||
jwt,
|
||||
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
|
||||
this.settingsStorageService.getSetting(KEY_STAY_SIGNED_IN) === 'true'
|
||||
);
|
||||
|
||||
this.router.navigate(['/']);
|
||||
|
@ -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@ghostfol.io</a> oder auf
|
||||
Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
Ich freue mich, von dir zu hören.<br />
|
||||
|
@ -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@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
I look forward to hearing from you.<br />
|
||||
|
@ -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@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@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
|
||||
|
@ -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@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
I look forward to hearing from you.<br />
|
||||
|
@ -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@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>. We
|
||||
are happy to discuss ideas.
|
||||
</p>
|
||||
<p>
|
||||
We would like to say thank you for all your feedback and support
|
||||
|
@ -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_">@ghostfolio_</a> or by
|
||||
e-mail via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
|
||||
</p>
|
||||
<p>
|
||||
We look forward to hearing from you.<br />
|
||||
|
@ -92,7 +92,7 @@
|
||||
>
|
||||
community or via Twitter
|
||||
<a href="https://twitter.com/ghostfolio_" target="_blank"
|
||||
>@ghostfolio_</a
|
||||
>@ghostfolio_</a
|
||||
>. We look forward to hearing from you!
|
||||
</p>
|
||||
</section>
|
||||
|
@ -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_">@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>
|
||||
|
@ -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_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
We look forward to hearing from you.<br />
|
||||
|
@ -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@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
|
||||
>@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@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
|
||||
>@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@ghostfol.io</a
|
||||
></ng-container
|
||||
>
|
||||
or start a discussion at
|
||||
|
@ -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,
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { PageEvent } from '@angular/material/paginator';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
@ -10,10 +12,11 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
|
||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DataSource, Order as OrderModel } from '@prisma/client';
|
||||
import { DataSource, Order as OrderModel, Prisma } from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
@ -30,12 +33,18 @@ import { ImportActivitiesDialogParams } from './import-activities-dialog/interfa
|
||||
})
|
||||
export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
public activities: Activity[];
|
||||
public dataSource: MatTableDataSource<Activity>;
|
||||
public defaultAccountId: string;
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToCreateActivity: boolean;
|
||||
public hasPermissionToDeleteActivity: boolean;
|
||||
public pageIndex = 0;
|
||||
public pageSize = DEFAULT_PAGE_SIZE;
|
||||
public routeQueryParams: Subscription;
|
||||
public sortColumn = 'date';
|
||||
public sortDirection: Prisma.SortOrder = 'desc';
|
||||
public totalItems: number;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -103,21 +112,48 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public fetchActivities() {
|
||||
this.dataService
|
||||
.fetchActivities({})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ activities }) => {
|
||||
this.activities = activities;
|
||||
if (this.user?.settings?.isExperimentalFeatures === true) {
|
||||
this.dataService
|
||||
.fetchActivities({
|
||||
skip: this.pageIndex * this.pageSize,
|
||||
sortColumn: this.sortColumn,
|
||||
sortDirection: this.sortDirection,
|
||||
take: this.pageSize
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ activities, count }) => {
|
||||
this.dataSource = new MatTableDataSource(activities);
|
||||
this.totalItems = count;
|
||||
|
||||
if (
|
||||
this.hasPermissionToCreateActivity &&
|
||||
this.activities?.length <= 0
|
||||
) {
|
||||
this.router.navigate([], { queryParams: { createDialog: true } });
|
||||
}
|
||||
if (this.hasPermissionToCreateActivity && this.totalItems <= 0) {
|
||||
this.router.navigate([], { queryParams: { createDialog: true } });
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
} else {
|
||||
this.dataService
|
||||
.fetchActivities({})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ activities }) => {
|
||||
this.activities = activities;
|
||||
|
||||
if (
|
||||
this.hasPermissionToCreateActivity &&
|
||||
this.activities?.length <= 0
|
||||
) {
|
||||
this.router.navigate([], { queryParams: { createDialog: true } });
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public onChangePage(page: PageEvent) {
|
||||
this.pageIndex = page.pageIndex;
|
||||
|
||||
this.fetchActivities();
|
||||
}
|
||||
|
||||
public onCloneActivity(aActivity: Activity) {
|
||||
|
@ -1,8 +1,31 @@
|
||||
<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-lazy
|
||||
*ngIf="user?.settings?.isExperimentalFeatures === true"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[dataSource]="dataSource"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
|
||||
[hasPermissionToExportActivities]="!hasImpersonationId"
|
||||
[locale]="user?.settings?.locale"
|
||||
[pageIndex]="pageIndex"
|
||||
[pageSize]="pageSize"
|
||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteActivity && !user.settings.isRestrictedView"
|
||||
[totalItems]="totalItems"
|
||||
(activityDeleted)="onDeleteActivity($event)"
|
||||
(activityToClone)="onCloneActivity($event)"
|
||||
(activityToUpdate)="onUpdateActivity($event)"
|
||||
(deleteAllActivities)="onDeleteAllActivities()"
|
||||
(export)="onExport($event)"
|
||||
(exportDrafts)="onExportDrafts($event)"
|
||||
(import)="onImport()"
|
||||
(importDividends)="onImportDividends()"
|
||||
(pageChanged)="onChangePage($event)"
|
||||
></gf-activities-table-lazy>
|
||||
<gf-activities-table
|
||||
*ngIf="user?.settings?.isExperimentalFeatures !== true"
|
||||
[activities]="activities"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
|
@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
|
||||
import { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
|
||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||
|
||||
import { ActivitiesPageRoutingModule } from './activities-page-routing.module';
|
||||
@ -17,6 +18,7 @@ import { GfImportActivitiesDialogModule } from './import-activities-dialog/impor
|
||||
ActivitiesPageRoutingModule,
|
||||
CommonModule,
|
||||
GfActivitiesTableModule,
|
||||
GfActivitiesTableLazyModule,
|
||||
GfCreateOrUpdateActivityDialogModule,
|
||||
GfImportActivitiesDialogModule,
|
||||
MatButtonModule,
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
@ -229,24 +230,27 @@
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">{{ product1.name }}</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">{{ product2.name }}</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Alternative</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">App</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Budgeting</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Community</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Family Office</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Fintech</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">{{ product1.name }}</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Investment</span>
|
||||
</li>
|
||||
@ -280,9 +284,15 @@
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">WealthTech</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth Management</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">{{ product2.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<nav aria-label="breadcrumb">
|
||||
|
@ -6,10 +6,13 @@ import { BasilFinancePageComponent } from './products/basil-finance-page.compone
|
||||
import { BeanvestPageComponent } from './products/beanvest-page.component';
|
||||
import { CapitallyPageComponent } from './products/capitally-page.component';
|
||||
import { CapMonPageComponent } from './products/capmon-page.component';
|
||||
import { CompoundPlanningPageComponent } from './products/compound-planning-page.component';
|
||||
import { CopilotMoneyPageComponent } from './products/copilot-money-page.component';
|
||||
import { DeFiPageComponent } from './products/de.fi-page.component';
|
||||
import { DeltaPageComponent } from './products/delta-page.component';
|
||||
import { DivvyDiaryPageComponent } from './products/divvydiary-page.component';
|
||||
import { EightFiguresPageComponent } from './products/eightfigures-page.component';
|
||||
import { EmpowerPageComponent } from './products/empower-page.component';
|
||||
import { ExirioPageComponent } from './products/exirio-page.component';
|
||||
import { FinaryPageComponent } from './products/finary-page.component';
|
||||
import { FinWisePageComponent } from './products/finwise-page.component';
|
||||
@ -37,9 +40,11 @@ import { SnowballAnalyticsPageComponent } from './products/snowball-analytics-pa
|
||||
import { StocklePageComponent } from './products/stockle-page.component';
|
||||
import { StockMarketEyePageComponent } from './products/stockmarketeye-page.component';
|
||||
import { SumioPageComponent } from './products/sumio-page.component';
|
||||
import { TillerPageComponent } from './products/tiller-page.component';
|
||||
import { UtlunaPageComponent } from './products/utluna-page.component';
|
||||
import { VyzerPageComponent } from './products/vyzer-page.component';
|
||||
import { WealthicaPageComponent } from './products/wealthica-page.component';
|
||||
import { WhalPageComponent } from './products/whal-page.component';
|
||||
import { YeekateePageComponent } from './products/yeekatee-page.component';
|
||||
import { YnabPageComponent } from './products/ynab-page.component';
|
||||
|
||||
@ -62,7 +67,6 @@ export const products: Product[] = [
|
||||
],
|
||||
name: 'Ghostfolio',
|
||||
origin: $localize`Switzerland`,
|
||||
pricingPerYear: '$24',
|
||||
region: $localize`Global`,
|
||||
slogan: 'Open Source Wealth Management',
|
||||
useAnonymously: true
|
||||
@ -125,6 +129,14 @@ export const products: Product[] = [
|
||||
note: 'CapMon.org has discontinued in 2023',
|
||||
slogan: 'Next Generation Assets Tracking'
|
||||
},
|
||||
{
|
||||
component: CompoundPlanningPageComponent,
|
||||
founded: 2019,
|
||||
key: 'compound-planning',
|
||||
name: 'Compound Planning',
|
||||
origin: $localize`United States`,
|
||||
slogan: 'Modern Wealth & Investment Management'
|
||||
},
|
||||
{
|
||||
component: CopilotMoneyPageComponent,
|
||||
founded: 2019,
|
||||
@ -136,6 +148,14 @@ export const products: Product[] = [
|
||||
pricingPerYear: '$70',
|
||||
slogan: 'Do money better with Copilot'
|
||||
},
|
||||
{
|
||||
component: DeFiPageComponent,
|
||||
founded: 2020,
|
||||
key: 'de.fi',
|
||||
languages: ['English'],
|
||||
name: 'De.Fi',
|
||||
slogan: 'DeFi Portfolio Tracker'
|
||||
},
|
||||
{
|
||||
component: DeltaPageComponent,
|
||||
founded: 2017,
|
||||
@ -159,6 +179,16 @@ export const products: Product[] = [
|
||||
pricingPerYear: '€65',
|
||||
slogan: 'Your personal Dividend Calendar'
|
||||
},
|
||||
{
|
||||
component: EmpowerPageComponent,
|
||||
founded: 2009,
|
||||
hasSelfHostingAbility: false,
|
||||
key: 'empower',
|
||||
name: 'Empower',
|
||||
note: 'Originally named as Personal Capital',
|
||||
origin: $localize`United States`,
|
||||
slogan: 'Get answers to your money questions'
|
||||
},
|
||||
{
|
||||
alias: '8figures',
|
||||
component: EightFiguresPageComponent,
|
||||
@ -455,6 +485,17 @@ export const products: Product[] = [
|
||||
pricingPerYear: '$20',
|
||||
slogan: 'Sum up and build your wealth.'
|
||||
},
|
||||
{
|
||||
component: TillerPageComponent,
|
||||
founded: 2016,
|
||||
hasFreePlan: false,
|
||||
key: 'tiller',
|
||||
name: 'Tiller',
|
||||
origin: $localize`United States`,
|
||||
pricingPerYear: '$79',
|
||||
slogan:
|
||||
'Your financial life in a spreadsheet, automatically updated each day'
|
||||
},
|
||||
{
|
||||
component: UtlunaPageComponent,
|
||||
hasFreePlan: true,
|
||||
@ -489,14 +530,23 @@ export const products: Product[] = [
|
||||
pricingPerYear: '$50',
|
||||
slogan: 'See all your investments in one place'
|
||||
},
|
||||
{
|
||||
component: WhalPageComponent,
|
||||
key: 'whal',
|
||||
name: 'Whal',
|
||||
origin: $localize`United States`,
|
||||
slogan: 'Manage your investments in one place'
|
||||
},
|
||||
{
|
||||
component: YeekateePageComponent,
|
||||
founded: 2021,
|
||||
hasFreePlan: true,
|
||||
hasSelfHostingAbility: false,
|
||||
key: 'yeekatee',
|
||||
languages: ['Deutsch', 'English', 'Español', 'Français', 'Italiano'],
|
||||
name: 'yeekatee',
|
||||
origin: $localize`Switzerland`,
|
||||
region: $localize`Switzerland`,
|
||||
region: $localize`Global`,
|
||||
slogan: 'Connect. Share. Invest.'
|
||||
},
|
||||
{
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -0,0 +1,32 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
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' },
|
||||
imports: [CommonModule, MatButtonModule, RouterModule],
|
||||
selector: 'gf-compound-planning-page',
|
||||
standalone: true,
|
||||
styleUrls: ['../product-page-template.scss'],
|
||||
templateUrl: '../product-page-template.html'
|
||||
})
|
||||
export class CompoundPlanningPageComponent extends BaseProductPageComponent {
|
||||
public product1 = products.find(({ key }) => {
|
||||
return key === 'ghostfolio';
|
||||
});
|
||||
|
||||
public product2 = products.find(({ key }) => {
|
||||
return key === 'compound-planning';
|
||||
});
|
||||
|
||||
public routerLinkAbout = ['/' + $localize`about`];
|
||||
public routerLinkFeatures = ['/' + $localize`features`];
|
||||
public routerLinkResourcesPersonalFinanceTools = [
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
];
|
||||
}
|
@ -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';
|
||||
});
|
||||
|
@ -0,0 +1,32 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
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' },
|
||||
imports: [CommonModule, MatButtonModule, RouterModule],
|
||||
selector: 'gf-de-fi-page',
|
||||
standalone: true,
|
||||
styleUrls: ['../product-page-template.scss'],
|
||||
templateUrl: '../product-page-template.html'
|
||||
})
|
||||
export class DeFiPageComponent extends BaseProductPageComponent {
|
||||
public product1 = products.find(({ key }) => {
|
||||
return key === 'ghostfolio';
|
||||
});
|
||||
|
||||
public product2 = products.find(({ key }) => {
|
||||
return key === 'de.fi';
|
||||
});
|
||||
|
||||
public routerLinkAbout = ['/' + $localize`about`];
|
||||
public routerLinkFeatures = ['/' + $localize`features`];
|
||||
public routerLinkResourcesPersonalFinanceTools = [
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
];
|
||||
}
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -0,0 +1,32 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
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' },
|
||||
imports: [CommonModule, MatButtonModule, RouterModule],
|
||||
selector: 'gf-empower-page',
|
||||
standalone: true,
|
||||
styleUrls: ['../product-page-template.scss'],
|
||||
templateUrl: '../product-page-template.html'
|
||||
})
|
||||
export class EmpowerPageComponent extends BaseProductPageComponent {
|
||||
public product1 = products.find(({ key }) => {
|
||||
return key === 'ghostfolio';
|
||||
});
|
||||
|
||||
public product2 = products.find(({ key }) => {
|
||||
return key === 'empower';
|
||||
});
|
||||
|
||||
public routerLinkAbout = ['/' + $localize`about`];
|
||||
public routerLinkFeatures = ['/' + $localize`features`];
|
||||
public routerLinkResourcesPersonalFinanceTools = [
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
];
|
||||
}
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
@ -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';
|
||||
});
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user