Compare commits

..

35 Commits

Author SHA1 Message Date
795a6a6799 Release 1.132.0 (#808) 2022-04-06 21:23:20 +02:00
2a854e2574 Various improvements (#807) 2022-04-06 21:21:53 +02:00
52d113e71f Feature/improve label of average price (#805)
* Improve label

* Update changelog
2022-04-06 18:02:21 +02:00
204c7360c3 Feature/prepare for localized date format (#803)
* Support localized date and number format

* Update changelog
2022-04-05 21:02:07 +02:00
fa41e25c8f Release/1.131.1 (#804)
* Add API version

* Update changelog
2022-04-04 07:30:17 +02:00
ba765b9de6 Release 1.131.0 (#797) 2022-04-02 19:32:47 +02:00
fa79196278 Feature/display values in base currency on mobile (#796)
* Display values in base currency on mobile

* Update changelog
2022-04-02 19:28:32 +02:00
d1230ca3ad Support coupon codes on Google Play (#795) 2022-04-02 18:47:53 +02:00
69a1316cfe Feature/upgrade prisma to 3.11.1 (#794)
* Upgrade prisma dependencies to version 3.11.1

* Update changelog
2022-04-02 17:38:47 +02:00
a256b783bc Feature/upgrade yahoo finance to 2.3.0 (#792)
* Upgrade yahoo-finance2

* Update changelog
2022-04-02 17:36:49 +02:00
ebbdd47fa2 Exclude google login callback endpoint from versioning (#793) 2022-04-02 17:36:15 +02:00
3d21e2eac6 Improve upgrade guide (#780) 2022-04-02 12:06:06 +02:00
bc117fe601 Fix port (#788) 2022-04-02 10:47:26 +02:00
65f6bcb166 Feature/harmonize algebraic sign of gross and net performance percent (#776)
* Harmonize algebraic sign

* Update changelog
2022-04-02 10:44:19 +02:00
b8c43ecf89 Add documentation for import API (#787) 2022-04-02 10:29:41 +02:00
1214127ec0 Feature/rename orders to activities in import and export (#786)
* Rename orders to activities

* Update changelog
2022-04-02 10:26:17 +02:00
e986310302 Improve price (#785) 2022-04-02 09:52:59 +02:00
6762572658 Feature/setup api versioning (#783)
* Setup API versioning

* Update changelog
2022-04-02 08:46:24 +02:00
eb77652d6a Clean up import (#784) 2022-04-02 08:03:17 +02:00
a7b59f4ec6 Extend prerequisites (#781) 2022-04-02 06:47:58 +02:00
dd71f2be45 Feature/improve pricing page (#778)
* Improve pricing page

* Update changelog
2022-04-01 19:58:33 +02:00
d530cb38fa Feature/add 7 days in coupon system (#777)
* Add 7 days

* Update changelog
2022-04-01 19:49:05 +02:00
16b79a7e60 Release 1.130.0 (#774) 2022-03-30 21:16:43 +02:00
7f0c98cae6 Feature/setup fire page with 4 percent rule (#773)
* Setup page for FIRE

* Update changelog
2022-03-30 21:14:49 +02:00
57e4163848 Feature/add 14 days in coupon system (#772)
* Add 14 days

* Update changelog
2022-03-29 20:49:44 +02:00
14773bf1aa Bugfix/fix duplicate currency conversion in account calculations (#771)
* Fix currency conversion (duplicate)

* Update changelog
2022-03-29 17:47:08 +02:00
1a8fc5757a Release 1.129.0 (#770) 2022-03-26 13:13:07 +01:00
b4848be914 Feature/add developed vs emerging markets calculation (#767)
* Add allocations by market

* Update changelog
2022-03-26 13:11:30 +01:00
2b4319454d Feature/add bonds and emergency fund to feature overview (#768)
* Add bonds and emergency fund

* Update changelog
2022-03-25 23:37:06 +01:00
e2faaf6faa Feature/add hover effect to page tabs (#764)
* Add hover effect

* Update changelog
2022-03-21 18:59:00 +01:00
86a1589834 Release/1.128.0 (#763) 2022-03-19 14:39:25 +01:00
9f67993c03 Feature/fix issue with recent transactions (#750)
* Fix percentage performance issue with recent transactions

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-03-19 14:33:43 +01:00
32fb3551dc Feature/add default market price to scraper configuration (#762)
* Add default market price to scraper configuration

* Update changelog
2022-03-19 12:17:28 +01:00
30411b1502 Feature/add hover to table (#760)
* Add hover

* Update changelog
2022-03-19 09:56:50 +01:00
eb0444603b Bugfix/fix user currency of public page (#761)
* Fix user currency

* Update changelog
2022-03-19 09:25:20 +01:00
92 changed files with 1338 additions and 335 deletions

View File

@ -5,6 +5,70 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.132.0 - 06.04.2022
### Added
- Added support for localization (date and number format) in user settings
### Changed
- Improved the label of the average price from _Ø Buy Price_ to _Average Unit Price_
## 1.131.1 - 04.04.2022
### Fixed
- Fixed the missing API version in the _Stripe_ success callback url
## 1.131.0 - 02.04.2022
### Added
- Added API versioning
- Added more durations in the coupon system
### Changed
- Display the value in base currency in the accounts table on mobile
- Display the value in base currency in the activities table on mobile
- Renamed `orders` to `activities` in import and export functionality
- Harmonized the algebraic sign of `currentGrossPerformancePercent` and `currentNetPerformancePercent` with `currentGrossPerformance` and `currentNetPerformance`
- Improved the pricing page
- Upgraded `prisma` from version `3.10.0` to `3.11.1`
- Upgraded `yahoo-finance2` from version `2.2.0` to `2.3.0`
## 1.130.0 - 30.03.2022
### Added
- Added a _FIRE_ (Financial Independence, Retire Early) section including the 4% rule
- Added more durations in the coupon system
### Fixed
- Fixed an issue with the currency conversion (duplicate) in the account calculations
## 1.129.0 - 26.03.2022
### Added
- Added the calculation for developed vs. emerging markets to the allocations page
- Added a hover effect to the page tabs
- Extended the feature overview page by _Bonds_ and _Emergency Fund_
## 1.128.0 - 19.03.2022
### Added
- Added the attribute `defaultMarketPrice` to the scraper configuration to improve the support for bonds
- Added a hover effect to the table style
### Fixed
- Fixed an issue with the user currency of the public page
- Fixed an issue of the performance calculation with recent activities in the new calculation engine
## 1.127.0 - 16.03.2022 ## 1.127.0 - 16.03.2022
### Changed ### Changed

View File

@ -79,6 +79,7 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- A local copy of this Git repository (clone)
### a. Run environment ### a. Run environment
@ -121,13 +122,11 @@ Open http://localhost:3333 in your browser and accomplish these steps:
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data 1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_ 1. Click _Sign out_ and check out the _Live Demo_
### Migrate Database ### Upgrade Version
With the following command you can keep your database schema in sync after a Ghostfolio version update: 1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d`
```bash 1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:migrate
```
## Development ## Development
@ -136,6 +135,7 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 14+) - [Node.js](https://nodejs.org/en/download) (version 14+)
- [Yarn](https://yarnpkg.com/en/docs/install) - [Yarn](https://yarnpkg.com/en/docs/install)
- A local copy of this Git repository (clone)
### Setup ### Setup
@ -162,10 +162,84 @@ Run `yarn start:client`
Run `yarn start:storybook` Run `yarn start:storybook`
### Migrate Database
With the following command you can keep your database schema in sync:
```bash
yarn database:push
```
## Testing ## Testing
Run `yarn test` Run `yarn test`
## Public API (experimental)
### Import Activities
#### Request
`POST http://localhost:3333/api/v1/import`
#### Authorization: Bearer Token
Set the header as follows:
```
"Authorization": "Bearer eyJh..."
```
#### Body
```
{
"activities": [
{
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-09-15T00:00:00.000Z",
"fee": 19,
"quantity": 5,
"symbol": "MSFT"
"type": "BUY",
"unitPrice": 298.58
}
]
}
```
| Field | Type | Description |
| ---------- | ------------------- | -------------------------------------------------- |
| accountId | string (`optional`) | Id of the account |
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
| date | string | Date in the format `ISO-8601` |
| fee | number | Fee of the activity |
| quantity | number | Quantity of the activity |
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
| type | string | `BUY` \| `DIVIDEND` \| `ITEM` \| `SELL` |
| unitPrice | number | Price per unit of the activity |
#### Response
##### Success
`201 Created`
##### Error
`400 Bad Request`
```
{
"error": "Bad Request",
"message": [
"activities.1 is a duplicate activity"
]
}
```
## Contributing ## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.

View File

@ -9,7 +9,9 @@ import {
Post, Post,
Req, Req,
Res, Res,
UseGuards UseGuards,
VERSION_NEUTRAL,
Version
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -51,6 +53,7 @@ export class AuthController {
@Get('google/callback') @Get('google/callback')
@UseGuards(AuthGuard('google')) @UseGuards(AuthGuard('google'))
@Version(VERSION_NEUTRAL)
public googleLoginCallback(@Req() req, @Res() res) { public googleLoginCallback(@Req() req, @Res() res) {
// Handles the Google OAuth2 callback // Handles the Google OAuth2 callback
const jwt: string = req.user.jwt; const jwt: string = req.user.jwt;

View File

@ -1,13 +1,6 @@
import { Export } from '@ghostfolio/common/interfaces'; import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
Controller,
Get,
Headers,
Inject,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';

View File

@ -14,7 +14,7 @@ export class ExportService {
activityIds?: string[]; activityIds?: string[];
userId: string; userId: string;
}): Promise<Export> { }): Promise<Export> {
let orders = await this.prismaService.order.findMany({ let activities = await this.prismaService.order.findMany({
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
select: { select: {
accountId: true, accountId: true,
@ -30,14 +30,14 @@ export class ExportService {
}); });
if (activityIds) { if (activityIds) {
orders = orders.filter((order) => { activities = activities.filter((activity) => {
return activityIds.includes(order.id); return activityIds.includes(activity.id);
}); });
} }
return { return {
meta: { date: new Date().toISOString(), version: environment.version }, meta: { date: new Date().toISOString(), version: environment.version },
orders: orders.map( activities: activities.map(
({ ({
accountId, accountId,
date, date,

View File

@ -6,5 +6,5 @@ export class ImportDataDto {
@IsArray() @IsArray()
@Type(() => CreateOrderDto) @Type(() => CreateOrderDto)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
orders: CreateOrderDto[]; activities: CreateOrderDto[];
} }

View File

@ -36,7 +36,7 @@ export class ImportController {
try { try {
return await this.importService.import({ return await this.importService.import({
orders: importData.orders, activities: importData.activities,
userId: this.request.user.id userId: this.request.user.id
}); });
} catch (error) { } catch (error) {

View File

@ -16,23 +16,23 @@ export class ImportService {
) {} ) {}
public async import({ public async import({
orders, activities,
userId userId
}: { }: {
orders: Partial<CreateOrderDto>[]; activities: Partial<CreateOrderDto>[];
userId: string; userId: string;
}): Promise<void> { }): Promise<void> {
for (const order of orders) { for (const activity of activities) {
if (!order.dataSource) { if (!activity.dataSource) {
if (order.type === 'ITEM') { if (activity.type === 'ITEM') {
order.dataSource = 'MANUAL'; activity.dataSource = 'MANUAL';
} else { } else {
order.dataSource = this.dataProviderService.getPrimaryDataSource(); activity.dataSource = this.dataProviderService.getPrimaryDataSource();
} }
} }
} }
await this.validateOrders({ orders, userId }); await this.validateActivities({ activities, userId });
const accountIds = (await this.accountService.getAccounts(userId)).map( const accountIds = (await this.accountService.getAccounts(userId)).map(
(account) => { (account) => {
@ -50,7 +50,7 @@ export class ImportService {
symbol, symbol,
type, type,
unitPrice unitPrice
} of orders) { } of activities) {
await this.orderService.createOrder({ await this.orderService.createOrder({
fee, fee,
quantity, quantity,
@ -79,24 +79,24 @@ export class ImportService {
} }
} }
private async validateOrders({ private async validateActivities({
orders, activities,
userId userId
}: { }: {
orders: Partial<CreateOrderDto>[]; activities: Partial<CreateOrderDto>[];
userId: string; userId: string;
}) { }) {
if ( if (
orders?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT') activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
) { ) {
throw new Error( throw new Error(
`Too many transactions (${this.configurationService.get( `Too many activities (${this.configurationService.get(
'MAX_ORDERS_TO_IMPORT' 'MAX_ORDERS_TO_IMPORT'
)} at most)` )} at most)`
); );
} }
const existingOrders = await this.orderService.orders({ const existingActivities = await this.orderService.orders({
include: { SymbolProfile: true }, include: { SymbolProfile: true },
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
where: { userId } where: { userId }
@ -105,22 +105,22 @@ export class ImportService {
for (const [ for (const [
index, index,
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice } { currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
] of orders.entries()) { ] of activities.entries()) {
const duplicateOrder = existingOrders.find((order) => { const duplicateActivity = existingActivities.find((activity) => {
return ( return (
order.SymbolProfile.currency === currency && activity.SymbolProfile.currency === currency &&
order.SymbolProfile.dataSource === dataSource && activity.SymbolProfile.dataSource === dataSource &&
isSameDay(order.date, parseISO(<string>(<unknown>date))) && isSameDay(activity.date, parseISO(<string>(<unknown>date))) &&
order.fee === fee && activity.fee === fee &&
order.quantity === quantity && activity.quantity === quantity &&
order.SymbolProfile.symbol === symbol && activity.SymbolProfile.symbol === symbol &&
order.type === type && activity.type === type &&
order.unitPrice === unitPrice activity.unitPrice === unitPrice
); );
}); });
if (duplicateOrder) { if (duplicateActivity) {
throw new Error(`orders.${index} is a duplicate transaction`); throw new Error(`activities.${index} is a duplicate activity`);
} }
if (dataSource !== 'MANUAL') { if (dataSource !== 'MANUAL') {
@ -130,13 +130,13 @@ export class ImportService {
if (quotes[symbol] === undefined) { if (quotes[symbol] === undefined) {
throw new Error( throw new Error(
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
} }
if (quotes[symbol].currency !== currency) { if (quotes[symbol].currency !== currency) {
throw new Error( throw new Error(
`orders.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"` `activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
); );
} }
} }

View File

@ -37,6 +37,9 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in
import { TransactionPoint } from './interfaces/transaction-point.interface'; import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculatorNew { export class PortfolioCalculatorNew {
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
true;
private static readonly ENABLE_LOGGING = false; private static readonly ENABLE_LOGGING = false;
private currency: string; private currency: string;
@ -688,6 +691,7 @@ export class PortfolioCalculatorNew {
let grossPerformanceAtStartDate = new Big(0); let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceFromSells = new Big(0); let grossPerformanceFromSells = new Big(0);
let initialValue: Big; let initialValue: Big;
let investmentAtStartDate: Big;
let lastAveragePrice = new Big(0); let lastAveragePrice = new Big(0);
let lastTransactionInvestment = new Big(0); let lastTransactionInvestment = new Big(0);
let lastValueOfInvestmentBeforeTransaction = new Big(0); let lastValueOfInvestmentBeforeTransaction = new Big(0);
@ -697,6 +701,7 @@ export class PortfolioCalculatorNew {
let totalInvestment = new Big(0); let totalInvestment = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0); let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
let totalUnits = new Big(0); let totalUnits = new Big(0);
let valueAtStartDate: Big;
// Add a synthetic order at the start and the end date // Add a synthetic order at the start and the end date
orders.push({ orders.push({
@ -774,13 +779,18 @@ export class PortfolioCalculatorNew {
order.unitPrice order.unitPrice
); );
if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0);
valueAtStartDate = valueOfInvestmentBeforeTransaction;
}
const transactionInvestment = order.quantity const transactionInvestment = order.quantity
.mul(order.unitPrice) .mul(order.unitPrice)
.mul(this.getFactor(order.type)); .mul(this.getFactor(order.type));
totalInvestment = totalInvestment.plus(transactionInvestment); totalInvestment = totalInvestment.plus(transactionInvestment);
if (totalInvestment.gt(maxTotalInvestment)) { if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
maxTotalInvestment = totalInvestment; maxTotalInvestment = totalInvestment;
} }
@ -898,12 +908,22 @@ export class PortfolioCalculatorNew {
.minus(grossPerformanceAtStartDate) .minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate)); .minus(fees.minus(feesAtStartDate));
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
maxTotalInvestment.minus(investmentAtStartDate)
);
const grossPerformancePercentage = const grossPerformancePercentage =
PortfolioCalculatorNew.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) || averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) || averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0) orders[indexOfStartOrder].unitPrice.eq(0)
? totalGrossPerformance.div(maxTotalInvestment) ? maxInvestmentBetweenStartAndEndDate.gt(0)
: unitPriceAtEndDate ? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.div(averagePriceAtEndDate) .div(averagePriceAtEndDate)
.div( .div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate) orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
@ -915,11 +935,17 @@ export class PortfolioCalculatorNew {
: new Big(0); : new Big(0);
const netPerformancePercentage = const netPerformancePercentage =
PortfolioCalculatorNew.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) || averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) || averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0) orders[indexOfStartOrder].unitPrice.eq(0)
? totalNetPerformance.div(maxTotalInvestment) ? maxInvestmentBetweenStartAndEndDate.gt(0)
: unitPriceAtEndDate ? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.minus(feesPerUnit) .minus(feesPerUnit)
.div(averagePriceAtEndDate) .div(averagePriceAtEndDate)
.div( .div(

View File

@ -13,8 +13,9 @@ export class PortfolioServiceStrategy {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
public get() { public get(newCalculationEngine?: boolean) {
if ( if (
newCalculationEngine ||
this.request.user?.Settings?.settings?.['isNewCalculationEngine'] === true this.request.user?.Settings?.settings?.['isNewCalculationEngine'] === true
) { ) {
return this.portfolioServiceNew; return this.portfolioServiceNew;

View File

@ -120,7 +120,7 @@ export class PortfolioController {
const { accounts, holdings, hasErrors } = const { accounts, holdings, hasErrors } =
await this.portfolioServiceStrategy await this.portfolioServiceStrategy
.get() .get(true)
.getDetails(impersonationId, this.request.user.id, range); .getDetails(impersonationId, this.request.user.id, range);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
@ -277,7 +277,7 @@ export class PortfolioController {
} }
const { holdings } = await this.portfolioServiceStrategy const { holdings } = await this.portfolioServiceStrategy
.get() .get(true)
.getDetails(access.userId, access.userId); .getDetails(access.userId, access.userId);
const portfolioPublicDetails: PortfolioPublicDetails = { const portfolioPublicDetails: PortfolioPublicDetails = {
@ -304,6 +304,7 @@ export class PortfolioController {
allocationCurrent: portfolioPosition.allocationCurrent, allocationCurrent: portfolioPosition.allocationCurrent,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: portfolioPosition.currency, currency: portfolioPosition.currency,
markets: portfolioPosition.markets,
name: portfolioPosition.name, name: portfolioPosition.name,
sectors: hasDetails ? portfolioPosition.sectors : [], sectors: hasDetails ? portfolioPosition.sectors : [],
value: portfolioPosition.value / totalValue value: portfolioPosition.value / totalValue
@ -319,6 +320,16 @@ export class PortfolioController {
public async getSummary( public async getSummary(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<PortfolioSummary> { ): Promise<PortfolioSummary> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let summary = await this.portfolioServiceStrategy let summary = await this.portfolioServiceStrategy
.get() .get()
.getSummary(impersonationId); .getSummary(impersonationId);

View File

@ -40,6 +40,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
import type { import type {
AccountWithValue, AccountWithValue,
DateRange, DateRange,
Market,
OrderWithAccount, OrderWithAccount,
RequestWithUser RequestWithUser
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
@ -71,6 +72,9 @@ import {
import { PortfolioCalculatorNew } from './portfolio-calculator-new'; import { PortfolioCalculatorNew } from './portfolio-calculator-new';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
const developedMarkets = require('../../assets/countries/developed-markets.json');
const emergingMarkets = require('../../assets/countries/emerging-markets.json');
@Injectable() @Injectable()
export class PortfolioServiceNew { export class PortfolioServiceNew {
public constructor( public constructor(
@ -107,21 +111,21 @@ export class PortfolioServiceNew {
} }
} }
const value = details.accounts[account.id]?.current ?? 0; const valueInBaseCurrency = details.accounts[account.id]?.current ?? 0;
const result = { const result = {
...account, ...account,
transactionCount, transactionCount,
value, valueInBaseCurrency,
balanceInBaseCurrency: this.exchangeRateDataService.toCurrency( balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance, account.balance,
account.currency, account.currency,
userCurrency userCurrency
), ),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
value, valueInBaseCurrency,
account.currency, userCurrency,
userCurrency account.currency
) )
}; };
@ -307,7 +311,10 @@ export class PortfolioServiceNew {
const emergencyFund = new Big( const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
); );
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; const userCurrency =
this.request.user?.Settings?.currency ??
user.Settings?.currency ??
baseCurrency;
const { orders, portfolioOrders, transactionPoints } = const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
@ -377,7 +384,31 @@ export class PortfolioServiceNew {
const value = item.quantity.mul(item.marketPrice); const value = item.quantity.mul(item.marketPrice);
const symbolProfile = symbolProfileMap[item.symbol]; const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol]; const dataProviderResponse = dataProviderResponses[item.symbol];
const markets: { [key in Market]: number } = {
developedMarkets: 0,
emergingMarkets: 0,
otherMarkets: 0
};
for (const country of symbolProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
}
}
holdings[item.symbol] = { holdings[item.symbol] = {
markets,
allocationCurrent: value.div(totalValue).toNumber(), allocationCurrent: value.div(totalValue).toNumber(),
allocationInvestment: item.investment.div(totalInvestment).toNumber(), allocationInvestment: item.investment.div(totalInvestment).toNumber(),
assetClass: symbolProfile.assetClass, assetClass: symbolProfile.assetClass,
@ -779,23 +810,33 @@ export class PortfolioServiceNew {
const hasErrors = currentPositions.hasErrors; const hasErrors = currentPositions.hasErrors;
const currentValue = currentPositions.currentValue.toNumber(); const currentValue = currentPositions.currentValue.toNumber();
const currentGrossPerformance = const currentGrossPerformance = currentPositions.grossPerformance;
currentPositions.grossPerformance.toNumber(); let currentGrossPerformancePercent =
const currentGrossPerformancePercent = currentPositions.grossPerformancePercentage;
currentPositions.grossPerformancePercentage.toNumber(); const currentNetPerformance = currentPositions.netPerformance;
const currentNetPerformance = currentPositions.netPerformance.toNumber(); let currentNetPerformancePercent =
const currentNetPerformancePercent = currentPositions.netPerformancePercentage;
currentPositions.netPerformancePercentage.toNumber();
if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
// If algebraic sign is different, harmonize it
currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
}
if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
// If algebraic sign is different, harmonize it
currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
}
return { return {
errors: currentPositions.errors, errors: currentPositions.errors,
hasErrors: currentPositions.hasErrors || hasErrors, hasErrors: currentPositions.hasErrors || hasErrors,
performance: { performance: {
currentGrossPerformance, currentValue,
currentGrossPerformancePercent, currentGrossPerformance: currentGrossPerformance.toNumber(),
currentNetPerformance, currentGrossPerformancePercent:
currentNetPerformancePercent, currentGrossPerformancePercent.toNumber(),
currentValue currentNetPerformance: currentNetPerformance.toNumber(),
currentNetPerformancePercent: currentNetPerformancePercent.toNumber()
} }
}; };
} }

View File

@ -106,21 +106,21 @@ export class PortfolioService {
} }
} }
const value = details.accounts[account.id]?.current ?? 0; const valueInBaseCurrency = details.accounts[account.id]?.current ?? 0;
const result = { const result = {
...account, ...account,
transactionCount, transactionCount,
value, valueInBaseCurrency,
balanceInBaseCurrency: this.exchangeRateDataService.toCurrency( balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance, account.balance,
account.currency, account.currency,
userCurrency userCurrency
), ),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
value, valueInBaseCurrency,
account.currency, userCurrency,
userCurrency account.currency
) )
}; };
@ -298,7 +298,10 @@ export class PortfolioService {
const emergencyFund = new Big( const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
); );
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; const userCurrency =
this.request.user?.Settings?.currency ??
user.Settings?.currency ??
baseCurrency;
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator(
this.currentRateService, this.currentRateService,
userCurrency userCurrency

View File

@ -45,7 +45,7 @@ export class SubscriptionService {
payment_method_types: ['card'], payment_method_types: ['card'],
success_url: `${this.configurationService.get( success_url: `${this.configurationService.get(
'ROOT_URL' 'ROOT_URL'
)}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}` )}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
}; };
if (couponId) { if (couponId) {

View File

@ -1,5 +1,6 @@
export interface UserSettings { export interface UserSettings {
emergencyFund?: number; emergencyFund?: number;
locale?: string;
isNewCalculationEngine?: boolean; isNewCalculationEngine?: boolean;
isRestrictedView?: boolean; isRestrictedView?: boolean;
} }

View File

@ -1,4 +1,4 @@
import { IsBoolean, IsNumber, IsOptional } from 'class-validator'; import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
export class UpdateUserSettingDto { export class UpdateUserSettingDto {
@IsNumber() @IsNumber()
@ -12,4 +12,8 @@ export class UpdateUserSettingDto {
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isRestrictedView?: boolean; isRestrictedView?: boolean;
@IsString()
@IsOptional()
locale?: string;
} }

View File

@ -2,17 +2,14 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config'; import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
hasPermission,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -63,8 +60,13 @@ export class UserController {
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getUser(@Param('id') id: string): Promise<User> { public async getUser(
return this.userService.getUser(this.request.user); @Headers('accept-language') acceptLanguage: string
): Promise<User> {
return this.userService.getUser(
this.request.user,
acceptLanguage?.split(',')?.[0]
);
} }
@Post() @Post()
@ -118,7 +120,7 @@ export class UserController {
}; };
for (const key in userSettings) { for (const key in userSettings) {
if (userSettings[key] === false) { if (userSettings[key] === false || userSettings[key] === null) {
delete userSettings[key]; delete userSettings[key];
} }
} }

View File

@ -33,14 +33,17 @@ export class UserService {
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService
) {} ) {}
public async getUser({ public async getUser(
{
Account, Account,
alias, alias,
id, id,
permissions, permissions,
Settings, Settings,
subscription subscription
}: UserWithSettings): Promise<IUser> { }: UserWithSettings,
aLocale = locale
): Promise<IUser> {
const access = await this.prismaService.access.findMany({ const access = await this.prismaService.access.findMany({
include: { include: {
User: true User: true
@ -63,8 +66,8 @@ export class UserService {
accounts: Account, accounts: Account,
settings: { settings: {
...(<UserSettings>Settings.settings), ...(<UserSettings>Settings.settings),
locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY, baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale,
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
} }
}; };

View File

@ -0,0 +1,26 @@
[
"AT",
"AU",
"BE",
"CA",
"CH",
"DE",
"DK",
"ES",
"FI",
"FR",
"GB",
"HK",
"IE",
"IL",
"IT",
"JP",
"LU",
"NL",
"NO",
"NZ",
"PT",
"SE",
"SG",
"US"
]

View File

@ -0,0 +1,28 @@
[
"AE",
"BR",
"CL",
"CN",
"CO",
"CY",
"CZ",
"EG",
"GR",
"HK",
"HU",
"ID",
"IN",
"KR",
"KW",
"MX",
"MY",
"PE",
"PH",
"PL",
"QA",
"SA",
"TH",
"TR",
"TW",
"ZA"
]

View File

@ -1,4 +1,4 @@
import { Logger, ValidationPipe } from '@nestjs/common'; import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
@ -7,8 +7,11 @@ import { environment } from './environments/environment';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.enableCors(); app.enableCors();
const globalPrefix = 'api'; app.enableVersioning({
app.setGlobalPrefix(globalPrefix); defaultVersion: '1',
type: VersioningType.URI
});
app.setGlobalPrefix('api');
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidNonWhitelisted: true, forbidNonWhitelisted: true,

View File

@ -1,10 +1,10 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioPosition PortfolioPosition
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioDetails } from '@ghostfolio/common/interfaces'; import { PortfolioDetails } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,6 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -13,7 +13,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format } from 'date-fns'; import { addDays, format, isBefore } from 'date-fns';
@Injectable() @Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface { export class GhostfolioScraperApiService implements DataProviderInterface {
@ -50,9 +50,27 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles( const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol] [symbol]
); );
const { selector, url } = symbolProfile.scraperConfiguration; const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration;
if (selector === undefined || url === undefined) { if (defaultMarketPrice) {
const historical: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[symbol]: {}
};
let date = from;
while (isBefore(date, to)) {
historical[symbol][format(date, DATE_FORMAT)] = {
marketPrice: defaultMarketPrice
};
date = addDays(date, 1);
}
return historical;
} else if (selector === undefined || url === undefined) {
return {}; return {};
} }

View File

@ -1,4 +1,5 @@
export interface ScraperConfiguration { export interface ScraperConfiguration {
defaultMarketPrice?: number;
selector: string; selector: string;
url: string; url: string;
} }

View File

@ -79,6 +79,7 @@ export class SymbolProfileService {
if (scraperConfiguration) { if (scraperConfiguration) {
return { return {
defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number,
selector: scraperConfiguration.selector as string, selector: scraperConfiguration.selector as string,
url: scraperConfiguration.url as string url: scraperConfiguration.url as string
}; };

View File

@ -1,16 +1,14 @@
import { import { DEFAULT_DATE_FORMAT_MONTH_YEAR } from '@ghostfolio/common/config';
DEFAULT_DATE_FORMAT, import { getDateFormatString } from '@ghostfolio/common/helper';
DEFAULT_DATE_FORMAT_MONTH_YEAR
} from '@ghostfolio/common/config';
export const DateFormats = { export const DateFormats = {
display: { display: {
dateInput: DEFAULT_DATE_FORMAT, dateInput: getDateFormatString(),
monthYearLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR, monthYearLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR,
dateA11yLabel: DEFAULT_DATE_FORMAT, dateA11yLabel: getDateFormatString(),
monthYearA11yLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR monthYearA11yLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR
}, },
parse: { parse: {
dateInput: DEFAULT_DATE_FORMAT dateInput: getDateFormatString()
} }
}; };

View File

@ -113,6 +113,13 @@ const routes: Routes = [
(m) => m.AnalysisPageModule (m) => m.AnalysisPageModule
) )
}, },
{
path: 'portfolio/fire',
loadChildren: () =>
import('./pages/portfolio/fire/fire-page.module').then(
(m) => m.FirePageModule
)
},
{ {
path: 'portfolio/report', path: 'portfolio/report',
loadChildren: () => loadChildren: () =>

View File

@ -78,10 +78,19 @@
</ng-container> </ng-container>
<ng-container matColumnDef="balance"> <ng-container matColumnDef="balance">
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell> <th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
>
Cash Balance Cash Balance
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1 text-right"
mat-cell
>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
@ -89,7 +98,11 @@
[value]="element.balance" [value]="element.balance"
></gf-value> ></gf-value>
</td> </td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell> <td
*matFooterCellDef
class="d-none d-lg-table-cell px-1 text-right"
mat-footer-cell
>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
@ -100,10 +113,19 @@
</ng-container> </ng-container>
<ng-container matColumnDef="value"> <ng-container matColumnDef="value">
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell> <th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
>
Value Value
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1 text-right"
mat-cell
>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
@ -111,7 +133,46 @@
[value]="element.value" [value]="element.value"
></gf-value> ></gf-value>
</td> </td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell> <td
*matFooterCellDef
class="d-none d-lg-table-cell px-1 text-right"
mat-footer-cell
>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalValueInBaseCurrency"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="valueInBaseCurrency">
<th
*matHeaderCellDef
class="d-lg-none d-xl-none px-1 text-right"
i18n
mat-header-cell
>
Value
</th>
<td
*matCellDef="let element"
class="d-lg-none d-xl-none px-1 text-right"
mat-cell
>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="element.valueInBaseCurrency"
></gf-value>
</td>
<td
*matFooterCellDef
class="d-lg-none d-xl-none px-1 text-right"
mat-footer-cell
>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"

View File

@ -50,7 +50,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
'transactions', 'transactions',
'balance', 'balance',
'value', 'value',
'currency' 'currency',
'valueInBaseCurrency'
]; ];
if (this.showActions) { if (this.showActions) {

View File

@ -8,8 +8,11 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import {
import { DATE_FORMAT } from '@ghostfolio/common/helper'; DATE_FORMAT,
getDateFormatString,
getLocale
} from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { import {
@ -35,13 +38,14 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-
export class AdminMarketDataDetailComponent implements OnChanges, OnInit { export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
@Input() dataSource: DataSource; @Input() dataSource: DataSource;
@Input() dateOfFirstActivity: string; @Input() dateOfFirstActivity: string;
@Input() locale = getLocale();
@Input() marketData: MarketData[]; @Input() marketData: MarketData[];
@Input() symbol: string; @Input() symbol: string;
@Output() marketDataChanged = new EventEmitter<boolean>(); @Output() marketDataChanged = new EventEmitter<boolean>();
public days = Array(31); public days = Array(31);
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat: string;
public deviceType: string; public deviceType: string;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public marketDataByMonth: { public marketDataByMonth: {
@ -62,6 +66,8 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
public ngOnInit() {} public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.defaultDateFormat = getDateFormatString(this.locale);
this.historicalDataItems = this.marketData.map((marketDataItem) => { this.historicalDataItems = this.marketData.map((marketDataItem) => {
return { return {
date: format(marketDataItem.date, DATE_FORMAT), date: format(marketDataItem.date, DATE_FORMAT),

View File

@ -7,8 +7,9 @@ import {
} from '@angular/core'; } from '@angular/core';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -23,9 +24,10 @@ import { takeUntil } from 'rxjs/operators';
export class AdminMarketDataComponent implements OnDestroy, OnInit { export class AdminMarketDataComponent implements OnDestroy, OnInit {
public currentDataSource: DataSource; public currentDataSource: DataSource;
public currentSymbol: string; public currentSymbol: string;
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat: string;
public marketData: AdminMarketDataItem[] = []; public marketData: AdminMarketDataItem[] = [];
public marketDataDetails: MarketData[] = []; public marketDataDetails: MarketData[] = [];
public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -35,8 +37,21 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService private dataService: DataService,
) {} private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user.settings.locale
);
}
});
}
/** /**
* Initializes the controller * Initializes the controller

View File

@ -65,6 +65,7 @@
<gf-admin-market-data-detail <gf-admin-market-data-detail
[dataSource]="item.dataSource" [dataSource]="item.dataSource"
[dateOfFirstActivity]="item.date" [dateOfFirstActivity]="item.date"
[locale]="user?.settings?.locale"
[marketData]="marketDataDetails" [marketData]="marketDataDetails"
[symbol]="item.symbol" [symbol]="item.symbol"
(marketDataChanged)="onMarketDataChanged($event)" (marketDataChanged)="onMarketDataChanged($event)"

View File

@ -5,7 +5,6 @@ import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_DATE_FORMAT,
PROPERTY_COUPONS, PROPERTY_COUPONS,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
@ -35,7 +34,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public customCurrencies: string[]; public customCurrencies: string[];
public dataGatheringInProgress: boolean; public dataGatheringInProgress: boolean;
public dataGatheringProgress: number; public dataGatheringProgress: number;
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public exchangeRates: { label1: string; label2: string; value: number }[]; public exchangeRates: { label1: string; label2: string; value: number }[];
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean; public hasPermissionForSystemMessage: boolean;

View File

@ -180,6 +180,8 @@
[value]="couponDuration" [value]="couponDuration"
(selectionChange)="onChangeCouponDuration($event.value)" (selectionChange)="onChangeCouponDuration($event.value)"
> >
<mat-option value="7 days">7 Days</mat-option>
<mat-option value="14 days">14 Days</mat-option>
<mat-option value="30 days">30 Days</mat-option> <mat-option value="30 days">30 Days</mat-option>
<mat-option value="1 year">1 Year</mat-option> <mat-option value="1 year">1 Year</mat-option>
</mat-select> </mat-select>

View File

@ -8,7 +8,7 @@
<div> <div>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin"> <ng-container *ngIf="data.hasPermissionToUseSocialLogin">
<div class="text-center"> <div class="text-center">
<a color="accent" href="/api/auth/google" mat-flat-button <a color="accent" href="/api/v1/auth/google" mat-flat-button
><ion-icon class="mr-1" name="logo-google"></ion-icon ><ion-icon class="mr-1" name="logo-google"></ion-icon
><span i18n>Sign in with Google</span></a ><span i18n>Sign in with Google</span></a
> >

View File

@ -7,6 +7,10 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import {
getNumberFormatDecimal,
getNumberFormatGroup
} from '@ghostfolio/common/helper';
import { import {
PortfolioPerformance, PortfolioPerformance,
ResponseError ResponseError
@ -50,13 +54,14 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
this.unit = this.baseCurrency; this.unit = this.baseCurrency;
new CountUp('value', this.performance?.currentValue, { new CountUp('value', this.performance?.currentValue, {
decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: decimalPlaces:
this.deviceType === 'mobile' && this.deviceType === 'mobile' &&
this.performance?.currentValue >= 100000 this.performance?.currentValue >= 100000
? 0 ? 0
: 2, : 2,
duration: 1, duration: 1,
separator: `'` separator: getNumberFormatGroup(this.locale)
}).start(); }).start();
} else if (this.performance?.currentValue === null) { } else if (this.performance?.currentValue === null) {
this.unit = '%'; this.unit = '%';
@ -65,9 +70,10 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
'value', 'value',
this.performance?.currentNetPerformancePercent * 100, this.performance?.currentNetPerformancePercent * 100,
{ {
decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: 2, decimalPlaces: 2,
duration: 0.75, duration: 1,
separator: `'` separator: getNumberFormatGroup(this.locale)
} }
).start(); ).start();
} }

View File

@ -21,7 +21,7 @@
<gf-line-chart <gf-line-chart
class="mb-4" class="mb-4"
benchmarkLabel="Buy Price" benchmarkLabel="Average Unit Price"
[benchmarkDataItems]="benchmarkDataItems" [benchmarkDataItems]="benchmarkDataItems"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[showGradient]="true" [showGradient]="true"
@ -53,7 +53,7 @@
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="Ø Buy Price" label="Average Unit Price"
size="medium" size="medium"
[currency]="SymbolProfile?.currency" [currency]="SymbolProfile?.currency"
[locale]="data.locale" [locale]="data.locale"

View File

@ -17,12 +17,14 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators'; import { catchError, tap } from 'rxjs/operators';
@Injectable() @Injectable()
export class HttpResponseInterceptor implements HttpInterceptor { export class HttpResponseInterceptor implements HttpInterceptor {
public hasPermissionForSubscription: boolean;
public info: InfoItem; public info: InfoItem;
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>; public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
@ -34,6 +36,11 @@ export class HttpResponseInterceptor implements HttpInterceptor {
private webAuthnService: WebAuthnService private webAuthnService: WebAuthnService
) { ) {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
} }
public intercept( public intercept(
@ -56,7 +63,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} else { } else {
this.snackBarRef = this.snackBar.open( this.snackBarRef = this.snackBar.open(
'This feature requires a subscription.', 'This feature requires a subscription.',
'Upgrade Plan', this.hasPermissionForSubscription ? 'Upgrade Plan' : undefined,
{ duration: 6000 } { duration: 6000 }
); );
} }

View File

@ -20,9 +20,11 @@ import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config'; import { baseCurrency } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { uniq } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { StripeService } from 'ngx-stripe'; import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
@ -45,13 +47,14 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public coupon: number; public coupon: number;
public couponId: string; public couponId: string;
public currencies: string[] = []; public currencies: string[] = [];
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat: string;
public deviceType: string; public deviceType: string;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToCreateAccess: boolean; public hasPermissionToCreateAccess: boolean;
public hasPermissionToDeleteAccess: boolean; public hasPermissionToDeleteAccess: boolean;
public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public locales = ['de', 'de-CH', 'en-GB', 'en-US'];
public price: number; public price: number;
public priceId: string; public priceId: string;
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>; public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
@ -101,6 +104,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user.settings.locale
);
this.hasPermissionToCreateAccess = hasPermission( this.hasPermissionToCreateAccess = hasPermission(
this.user.permissions, this.user.permissions,
permissions.createAccess permissions.createAccess
@ -121,6 +128,9 @@ export class AccountPageComponent implements OnDestroy, OnInit {
permissions.updateViewMode permissions.updateViewMode
); );
this.locales.push(this.user.settings.locale);
this.locales = uniq(this.locales.sort());
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
@ -143,6 +153,24 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.update(); this.update();
} }
public onChangeUserSetting(aKey: string, aValue: string) {
this.dataService
.putUserSetting({ [aKey]: aValue })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onChangeUserSettings(aKey: string, aValue: string) { public onChangeUserSettings(aKey: string, aValue: string) {
const settings = { ...this.user.settings, [aKey]: aValue }; const settings = { ...this.user.settings, [aKey]: aValue };

View File

@ -27,9 +27,8 @@
Valid until {{ user?.subscription?.expiresAt | date: Valid until {{ user?.subscription?.expiresAt | date:
defaultDateFormat }} defaultDateFormat }}
</div> </div>
<div <div *ngIf="user?.subscription?.type === 'Basic'">
*ngIf="hasPermissionForSubscription && user?.subscription?.type === 'Basic'" <ng-container *ngIf="hasPermissionForSubscription">
>
<button <button
color="primary" color="primary"
i18n i18n
@ -39,14 +38,17 @@
Upgrade Upgrade
</button> </button>
<div *ngIf="price" class="mt-1"> <div *ngIf="price" class="mt-1">
{{ baseCurrency }}
<ng-container *ngIf="coupon" <ng-container *ngIf="coupon"
>{{ price - coupon | number : '1.2-2' }} ><del class="text-muted"
<del>{{ price }}</del> >{{ baseCurrency }}&nbsp;{{ price }}</del
</ng-container> >&nbsp;{{ baseCurrency }}&nbsp;{{ price - coupon
<ng-container *ngIf="!coupon">{{ price }}</ng-container> }}</ng-container
<span i18n> per year</span> >
<ng-container *ngIf="!coupon"
>{{ baseCurrency }}&nbsp;{{ price }}</ng-container
>&nbsp;<span i18n>per year</span>
</div> </div>
</ng-container>
<a <a
*ngIf="!user?.subscription?.expiresAt" *ngIf="!user?.subscription?.expiresAt"
class="mr-2 my-2" class="mr-2 my-2"
@ -109,6 +111,31 @@
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div class="align-items-center d-flex mb-2">
<div class="pr-1 w-50">
<div i18n>Locale</div>
<div class="hint-text text-muted" i18n>
Date and number format
</div>
</div>
<div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100">
<mat-select
name="locale"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.locale"
(selectionChange)="onChangeUserSetting('locale', $event.value)"
>
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let locale of locales"
[value]="locale"
>{{ locale }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="d-flex"> <div class="d-flex">
<div class="align-items-center d-flex pr-1 pt-1 w-50" i18n> <div class="align-items-center d-flex pr-1 pt-1 w-50" i18n>
View Mode View Mode

View File

@ -10,6 +10,7 @@ import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module'; import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AccountPageRoutingModule } from './account-page-routing.module'; import { AccountPageRoutingModule } from './account-page-routing.module';
import { AccountPageComponent } from './account-page.component'; import { AccountPageComponent } from './account-page.component';
@ -24,6 +25,7 @@ import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-di
FormsModule, FormsModule,
GfCreateOrUpdateAccessDialogModule, GfCreateOrUpdateAccessDialogModule,
GfPortfolioAccessTableModule, GfPortfolioAccessTableModule,
GfValueModule,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
MatDialogModule, MatDialogModule,

View File

@ -29,6 +29,12 @@
color: rgba(var(--palette-primary-500), 1); color: rgba(var(--palette-primary-500), 1);
opacity: 1; opacity: 1;
} }
.mat-tab-link {
&:hover {
opacity: 0.75;
}
}
} }
} }
} }

View File

@ -32,6 +32,17 @@
</div> </div>
</mat-card> </mat-card>
</div> </div>
<div class="col-xs-12 col-md-4 mb-3">
<mat-card class="d-flex flex-column h-100">
<div class="flex-grow-1">
<h4 i18n>Bonds</h4>
<p class="m-0">
Manage your investment in bonds and other assets with fixed
income.
</p>
</div>
</mat-card>
</div>
<div class="col-xs-12 col-md-4 mb-3"> <div class="col-xs-12 col-md-4 mb-3">
<mat-card class="d-flex flex-column h-100"> <mat-card class="d-flex flex-column h-100">
<div class="flex-grow-1"> <div class="flex-grow-1">
@ -64,6 +75,17 @@
</div> </div>
</mat-card> </mat-card>
</div> </div>
<div class="col-xs-12 col-md-4 mb-3">
<mat-card class="d-flex flex-column h-100">
<div class="flex-grow-1">
<h4 class="align-items-center d-flex" i18n>Emergency Fund</h4>
<p class="m-0">
Define your emergency fund you are comfortable with for
difficult times.
</p>
</div>
</mat-card>
</div>
<div class="col-xs-12 col-md-4 mb-3"> <div class="col-xs-12 col-md-4 mb-3">
<mat-card class="d-flex flex-column h-100"> <mat-card class="d-flex flex-column h-100">
<div class="flex-grow-1"> <div class="flex-grow-1">

View File

@ -30,6 +30,12 @@
color: rgba(var(--palette-primary-500), 1); color: rgba(var(--palette-primary-500), 1);
opacity: 1; opacity: 1;
} }
.mat-tab-link {
&:hover {
opacity: 0.75;
}
}
} }
} }
} }

View File

@ -14,7 +14,7 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ToggleOption } from '@ghostfolio/common/types'; import { Market, ToggleOption } from '@ghostfolio/common/types';
import { Account, AssetClass, DataSource } from '@prisma/client'; import { Account, AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
@ -42,6 +42,9 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
public markets: {
[key in Market]: { name: string; value: number };
};
public period = 'current'; public period = 'current';
public periodOptions: ToggleOption[] = [ public periodOptions: ToggleOption[] = [
{ label: 'Initial', value: 'original' }, { label: 'Initial', value: 'original' },
@ -160,6 +163,20 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: 0 value: 0
} }
}; };
this.markets = {
developedMarkets: {
name: 'developedMarkets',
value: 0
},
emergingMarkets: {
name: 'emergingMarkets',
value: 0
},
otherMarkets: {
name: 'otherMarkets',
value: 0
}
};
this.positions = {}; this.positions = {};
this.positionsArray = []; this.positionsArray = [];
this.sectors = { this.sectors = {
@ -219,6 +236,16 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
// Prepare analysis data by continents, countries and sectors except for cash // Prepare analysis data by continents, countries and sectors except for cash
if (position.countries.length > 0) { if (position.countries.length > 0) {
this.markets.developedMarkets.value +=
position.markets.developedMarkets *
(aPeriod === 'original' ? position.investment : position.value);
this.markets.emergingMarkets.value +=
position.markets.emergingMarkets *
(aPeriod === 'original' ? position.investment : position.value);
this.markets.otherMarkets.value +=
position.markets.otherMarkets *
(aPeriod === 'original' ? position.investment : position.value);
for (const country of position.countries) { for (const country of position.countries) {
const { code, continent, name, weight } = country; const { code, continent, name, weight } = country;
@ -294,6 +321,18 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
}; };
} }
} }
const marketsTotal =
this.markets.developedMarkets.value +
this.markets.emergingMarkets.value +
this.markets.otherMarkets.value;
this.markets.developedMarkets.value =
this.markets.developedMarkets.value / marketsTotal;
this.markets.emergingMarkets.value =
this.markets.emergingMarkets.value / marketsTotal;
this.markets.otherMarkets.value =
this.markets.otherMarkets.value / marketsTotal;
} }
public onChangePeriod(aValue: string) { public onChangePeriod(aValue: string) {

View File

@ -190,6 +190,32 @@
[countries]="countries" [countries]="countries"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
></gf-world-map-chart> ></gf-world-map-chart>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
<gf-value
label="Developed Markets"
size="large"
[isPercent]="true"
[value]="markets?.developedMarkets?.value"
></gf-value>
</div>
<div class="col-xs-12 col-md-4 my-2">
<gf-value
label="Emerging Markets"
size="large"
[isPercent]="true"
[value]="markets?.emergingMarkets?.value"
></gf-value>
</div>
<div class="col-xs-12 col-md-4 my-2">
<gf-value
label="Other Markets"
size="large"
[isPercent]="true"
[value]="markets?.otherMarkets?.value"
></gf-value>
</div>
</div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>

View File

@ -5,6 +5,7 @@ import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module'; import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module'; import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AllocationsPageRoutingModule } from './allocations-page-routing.module'; import { AllocationsPageRoutingModule } from './allocations-page-routing.module';
import { AllocationsPageComponent } from './allocations-page.component'; import { AllocationsPageComponent } from './allocations-page.component';
@ -19,6 +20,7 @@ import { AllocationsPageComponent } from './allocations-page.component';
GfPositionsTableModule, GfPositionsTableModule,
GfToggleModule, GfToggleModule,
GfWorldMapChartModule, GfWorldMapChartModule,
GfValueModule,
MatCardModule MatCardModule
], ],
providers: [], providers: [],

View File

@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { FirePageComponent } from './fire-page.component';
const routes: Routes = [
{ path: '', component: FirePageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FirePageRoutingModule {}

View File

@ -0,0 +1,86 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
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 { User } from '@ghostfolio/common/interfaces';
import Big from 'big.js';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'page' },
selector: 'gf-fire-page',
styleUrls: ['./fire-page.scss'],
templateUrl: './fire-page.html'
})
export class FirePageComponent implements OnDestroy, OnInit {
public fireWealth: number;
public hasImpersonationId: boolean;
public isLoading = false;
public user: User;
public withdrawalRatePerMonth: number;
public withdrawalRatePerYear: number;
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.isLoading = true;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
this.dataService
.fetchPortfolioSummary()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ cash, currentValue }) => {
if (cash === null || currentValue === null) {
return;
}
this.fireWealth = new Big(currentValue).plus(cash).toNumber();
this.withdrawalRatePerYear = new Big(this.fireWealth)
.mul(4)
.div(100)
.toNumber();
this.withdrawalRatePerMonth = new Big(this.withdrawalRatePerYear)
.div(12)
.toNumber();
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,53 @@
<div class="container">
<div class="row">
<div class="col-lg">
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3>
<div class="mb-4">
<h4 i18n>4% Rule</h4>
<div *ngIf="isLoading">
<ngx-skeleton-loader
animation="pulse"
class="my-1"
[theme]="{
height: '1rem',
width: '100%'
}"
></ngx-skeleton-loader>
<ngx-skeleton-loader
animation="pulse"
[theme]="{
height: '1rem',
width: '10rem'
}"
></ngx-skeleton-loader>
</div>
<div *ngIf="!isLoading">
If you retire today, you would be able to withdraw
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[currency]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerYear"
></gf-value>
per year</span
>
or
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[currency]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerMonth"
></gf-value>
per month</span
>, based on your net worth of
<gf-value
class="d-inline-block"
[currency]="user?.settings?.baseCurrency"
[value]="fireWealth"
></gf-value>
(excluding emergency fund) and a withdrawal rate of 4%.
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,19 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { FirePageRoutingModule } from './fire-page-routing.module';
import { FirePageComponent } from './fire-page.component';
@NgModule({
declarations: [FirePageComponent],
imports: [
CommonModule,
FirePageRoutingModule,
GfValueModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class FirePageModule {}

View File

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

View File

@ -25,7 +25,7 @@
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span i18n>Allocations</span> <span i18n>Allocations</span>
<ion-icon <ion-icon
*ngIf="hasPermissionForSubscription" *ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted" class="ml-1 text-muted"
name="diamond-outline" name="diamond-outline"
></ion-icon> ></ion-icon>
@ -38,7 +38,6 @@
<a <a
color="primary" color="primary"
mat-button mat-button
[disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'"
[routerLink]="['/portfolio', 'allocations']" [routerLink]="['/portfolio', 'allocations']"
> >
<span i18n>Open Allocations</span> <span i18n>Open Allocations</span>
@ -52,7 +51,7 @@
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span i18n>Analysis</span> <span i18n>Analysis</span>
<ion-icon <ion-icon
*ngIf="hasPermissionForSubscription" *ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted" class="ml-1 text-muted"
name="diamond-outline" name="diamond-outline"
></ion-icon> ></ion-icon>
@ -65,7 +64,6 @@
<a <a
color="primary" color="primary"
mat-button mat-button
[disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'"
[routerLink]="['/portfolio', 'analysis']" [routerLink]="['/portfolio', 'analysis']"
> >
<span i18n>Open Analysis</span> <span i18n>Open Analysis</span>
@ -79,7 +77,7 @@
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span i18n>X-ray</span> <span i18n>X-ray</span>
<ion-icon <ion-icon
*ngIf="hasPermissionForSubscription" *ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted" class="ml-1 text-muted"
name="diamond-outline" name="diamond-outline"
></ion-icon> ></ion-icon>
@ -89,17 +87,34 @@
risks in your portfolio. risks in your portfolio.
</div> </div>
<div class="mt-2 text-right"> <div class="mt-2 text-right">
<a <a color="primary" mat-button [routerLink]="['/portfolio', 'report']">
color="primary"
mat-button
[disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'"
[routerLink]="['/portfolio', 'report']"
>
<span i18n>Open X-ray</span> <span i18n>Open X-ray</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon> <ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a> </a>
</div> </div>
</mat-card> </mat-card>
</div> </div>
<div class="col-xs-12 col-md-6 mb-3">
<mat-card class="d-flex flex-column h-100">
<h4 class="align-items-center d-flex">
<span i18n>FIRE</span>
<ion-icon
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
</h4>
<div class="flex-grow-1">
Ghostfolio FIRE calculates metrics for the
<i>Financial Independence, Retire Early</i> lifestyle.
</div>
<div class="mt-2 text-right">
<a color="primary" mat-button [routerLink]="['/portfolio', 'fire']">
<span i18n>Open FIRE</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a>
</div>
</mat-card>
</div>
</div> </div>
</div> </div>

View File

@ -27,12 +27,12 @@ export class ImportTransactionDialog implements OnDestroy {
public ngOnInit() { public ngOnInit() {
for (const message of this.data.messages) { for (const message of this.data.messages) {
if (message.includes('orders.')) { if (message.includes('activities.')) {
let [index] = message.split(' '); let [index] = message.split(' ');
index = index.replace('orders.', ''); index = index.replace('activities.', '');
[index] = index.split('.'); [index] = index.split('.');
this.details.push(this.data.orders[index]); this.details.push(this.data.activities[index]);
} else { } else {
this.details.push(''); this.details.push('');
} }

View File

@ -1,5 +1,5 @@
export interface ImportTransactionDialogParams { export interface ImportTransactionDialogParams {
activities: any[];
deviceType: string; deviceType: string;
messages: string[]; messages: string[];
orders: any[];
} }

View File

@ -185,19 +185,31 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
if (file.name.endsWith('.json')) { if (file.name.endsWith('.json')) {
const content = JSON.parse(fileContent); const content = JSON.parse(fileContent);
if (!isArray(content.orders)) { if (!isArray(content.activities)) {
if (isArray(content.orders)) {
this.handleImportError({
activities: [],
error: {
error: {
message: [`orders needs to be renamed to activities`]
}
}
});
return;
} else {
throw new Error(); throw new Error();
} }
}
try { try {
await this.importTransactionsService.importJson({ await this.importTransactionsService.importJson({
content: content.orders content: content.activities
}); });
this.handleImportSuccess(); this.handleImportSuccess();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
this.handleImportError({ error, orders: content.orders }); this.handleImportError({ error, activities: content.activities });
} }
return; return;
@ -212,10 +224,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
} catch (error) { } catch (error) {
console.error(error); console.error(error);
this.handleImportError({ this.handleImportError({
activities: error?.activities ?? [],
error: { error: {
error: { message: error?.error?.message ?? [error?.message] } error: { message: error?.error?.message ?? [error?.message] }
}, }
orders: error?.orders ?? []
}); });
} }
@ -226,8 +238,8 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
} catch (error) { } catch (error) {
console.error(error); console.error(error);
this.handleImportError({ this.handleImportError({
error: { error: { message: ['Unexpected format'] } }, activities: [],
orders: [] error: { error: { message: ['Unexpected format'] } }
}); });
} }
}; };
@ -281,12 +293,18 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private handleImportError({ error, orders }: { error: any; orders: any[] }) { private handleImportError({
activities,
error
}: {
activities: any[];
error: any;
}) {
this.snackBar.dismiss(); this.snackBar.dismiss();
this.dialog.open(ImportTransactionDialog, { this.dialog.open(ImportTransactionDialog, {
data: { data: {
orders, activities,
deviceType: this.deviceType, deviceType: this.deviceType,
messages: error?.error?.message messages: error?.error?.message
}, },

View File

@ -178,16 +178,19 @@
</div> </div>
<p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p> <p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p>
<p class="h5 text-right" [hidden]="!price"> <p class="h5 text-right" [hidden]="!price">
<span class="font-weight-normal" <span class="font-weight-normal">
>{{ baseCurrency }}
<ng-container *ngIf="coupon" <ng-container *ngIf="coupon"
><strong>{{ price - coupon | number : '1.2-2' }} </strong> ><del class="text-muted"
<del>{{ price }}</del> >{{ baseCurrency }}&nbsp;{{ price }}</del
>&nbsp;{{ baseCurrency }}&nbsp;<strong
>{{ price - coupon }}</strong
>
</ng-container> </ng-container>
<ng-container *ngIf="!coupon" <ng-container *ngIf="!coupon"
><strong>{{ price }}</strong></ng-container >{{ baseCurrency }}&nbsp;<strong
> >{{ price }}</strong
<span i18n> per year</span></span ></ng-container
>&nbsp;<span i18n>per year</span></span
> >
</p> </p>
</mat-card> </mat-card>

View File

@ -7,6 +7,7 @@ import {
PortfolioPosition, PortfolioPosition,
PortfolioPublicDetails PortfolioPublicDetails
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Market } from '@ghostfolio/common/types';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
@ -26,6 +27,9 @@ export class PublicPageComponent implements OnInit {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public deviceType: string; public deviceType: string;
public markets: {
[key in Market]: { name: string; value: number };
};
public portfolioPublicDetails: PortfolioPublicDetails; public portfolioPublicDetails: PortfolioPublicDetails;
public positions: { public positions: {
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name' | 'value'>; [symbol: string]: Pick<PortfolioPosition, 'currency' | 'name' | 'value'>;
@ -96,6 +100,20 @@ export class PublicPageComponent implements OnInit {
value: 0 value: 0
} }
}; };
this.markets = {
developedMarkets: {
name: 'developedMarkets',
value: 0
},
emergingMarkets: {
name: 'emergingMarkets',
value: 0
},
otherMarkets: {
name: 'otherMarkets',
value: 0
}
};
this.positions = {}; this.positions = {};
this.sectors = { this.sectors = {
[UNKNOWN_KEY]: { [UNKNOWN_KEY]: {
@ -123,6 +141,13 @@ export class PublicPageComponent implements OnInit {
}; };
if (position.countries.length > 0) { if (position.countries.length > 0) {
this.markets.developedMarkets.value +=
position.markets.developedMarkets * position.value;
this.markets.emergingMarkets.value +=
position.markets.emergingMarkets * position.value;
this.markets.otherMarkets.value +=
position.markets.otherMarkets * position.value;
for (const country of position.countries) { for (const country of position.countries) {
const { code, continent, name, weight } = country; const { code, continent, name, weight } = country;
@ -176,6 +201,18 @@ export class PublicPageComponent implements OnInit {
value: position.value value: position.value
}; };
} }
const marketsTotal =
this.markets.developedMarkets.value +
this.markets.emergingMarkets.value +
this.markets.otherMarkets.value;
this.markets.developedMarkets.value =
this.markets.developedMarkets.value / marketsTotal;
this.markets.emergingMarkets.value =
this.markets.emergingMarkets.value / marketsTotal;
this.markets.otherMarkets.value =
this.markets.otherMarkets.value / marketsTotal;
} }
public ngOnDestroy() { public ngOnDestroy() {

View File

@ -79,12 +79,38 @@
[countries]="countries" [countries]="countries"
[isInPercent]="true" [isInPercent]="true"
></gf-world-map-chart> ></gf-world-map-chart>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
<gf-value
label="Developed Markets"
size="large"
[isPercent]="true"
[value]="markets?.developedMarkets?.value"
></gf-value>
</div>
<div class="col-xs-12 col-md-4 my-2">
<gf-value
label="Emerging Markets"
size="large"
[isPercent]="true"
[value]="markets?.emergingMarkets?.value"
></gf-value>
</div>
<div class="col-xs-12 col-md-4 my-2">
<gf-value
label="Other Markets"
size="large"
[isPercent]="true"
[value]="markets?.otherMarkets?.value"
></gf-value>
</div>
</div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
</div> </div>
<div class="row my-5"> <div class="row my-5">
<div class="col-md-8 offset-md-2"> <div class="col-md-10 offset-md-1">
<h2 class="h4 mb-1 text-center"> <h2 class="h4 mb-1 text-center">
Would you like to <strong>refine</strong> your Would you like to <strong>refine</strong> your
<strong>personal investment strategy</strong>? <strong>personal investment strategy</strong>?

View File

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module'; import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module'; import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { PublicPageRoutingModule } from './public-page-routing.module'; import { PublicPageRoutingModule } from './public-page-routing.module';
import { PublicPageComponent } from './public-page.component'; import { PublicPageComponent } from './public-page.component';
@ -14,6 +15,7 @@ import { PublicPageComponent } from './public-page.component';
imports: [ imports: [
CommonModule, CommonModule,
GfPortfolioProportionChartModule, GfPortfolioProportionChartModule,
GfValueModule,
GfWorldMapChartModule, GfWorldMapChartModule,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,

View File

@ -35,7 +35,7 @@
> >
or or
</div> </div>
<a color="accent" href="/api/auth/google" mat-flat-button <a color="accent" href="/api/v1/auth/google" mat-flat-button
><ion-icon class="mr-1" name="logo-google"></ion-icon ><ion-icon class="mr-1" name="logo-google"></ion-icon
><span i18n>Continue with Google</span></a ><span i18n>Continue with Google</span></a
> >

View File

@ -28,6 +28,12 @@
color: rgba(var(--palette-primary-500), 1); color: rgba(var(--palette-primary-500), 1);
opacity: 1; opacity: 1;
} }
.mat-tab-link {
&:hover {
opacity: 0.75;
}
}
} }
} }
} }

View File

@ -19,7 +19,7 @@ export class AdminService {
public deleteProfileData({ dataSource, symbol }: UniqueAsset) { public deleteProfileData({ dataSource, symbol }: UniqueAsset) {
return this.http.delete<void>( return this.http.delete<void>(
`/api/admin/profile-data/${dataSource}/${symbol}` `/api/v1/admin/profile-data/${dataSource}/${symbol}`
); );
} }
@ -31,7 +31,7 @@ export class AdminService {
symbol: string; symbol: string;
}): Observable<AdminMarketDataDetails> { }): Observable<AdminMarketDataDetails> {
return this.http return this.http
.get<any>(`/api/admin/market-data/${dataSource}/${symbol}`) .get<any>(`/api/v1/admin/market-data/${dataSource}/${symbol}`)
.pipe( .pipe(
map((data) => { map((data) => {
for (const item of data.marketData) { for (const item of data.marketData) {
@ -43,16 +43,16 @@ export class AdminService {
} }
public gatherMax() { public gatherMax() {
return this.http.post<void>(`/api/admin/gather/max`, {}); return this.http.post<void>(`/api/v1/admin/gather/max`, {});
} }
public gatherProfileData() { public gatherProfileData() {
return this.http.post<void>(`/api/admin/gather/profile-data`, {}); return this.http.post<void>(`/api/v1/admin/gather/profile-data`, {});
} }
public gatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) { public gatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
return this.http.post<void>( return this.http.post<void>(
`/api/admin/gather/profile-data/${dataSource}/${symbol}`, `/api/v1/admin/gather/profile-data/${dataSource}/${symbol}`,
{} {}
); );
} }
@ -64,7 +64,7 @@ export class AdminService {
}: UniqueAsset & { }: UniqueAsset & {
date?: Date; date?: Date;
}) { }) {
let url = `/api/admin/gather/${dataSource}/${symbol}`; let url = `/api/v1/admin/gather/${dataSource}/${symbol}`;
if (date) { if (date) {
url = `${url}/${format(date, DATE_FORMAT)}`; url = `${url}/${format(date, DATE_FORMAT)}`;
@ -82,7 +82,7 @@ export class AdminService {
date: Date; date: Date;
symbol: string; symbol: string;
}) { }) {
const url = `/api/symbol/${dataSource}/${symbol}/${format( const url = `/api/v1/symbol/${dataSource}/${symbol}/${format(
date, date,
DATE_FORMAT DATE_FORMAT
)}`; )}`;
@ -101,7 +101,7 @@ export class AdminService {
marketData: UpdateMarketDataDto; marketData: UpdateMarketDataDto;
symbol: string; symbol: string;
}) { }) {
const url = `/api/admin/market-data/${dataSource}/${symbol}/${format( const url = `/api/v1/admin/market-data/${dataSource}/${symbol}/${format(
date, date,
DATE_FORMAT DATE_FORMAT
)}`; )}`;

View File

@ -8,6 +8,6 @@ export class CacheService {
public constructor(private http: HttpClient) {} public constructor(private http: HttpClient) {}
public flush() { public flush() {
return this.http.post<any>(`/api/cache/flush`, {}); return this.http.post<any>(`/api/v1/cache/flush`, {});
} }
} }

View File

@ -23,12 +23,10 @@ import {
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformance,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPublicDetails, PortfolioPublicDetails,
PortfolioReport, PortfolioReport,
PortfolioSummary, PortfolioSummary,
UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -52,46 +50,46 @@ export class DataService {
couponId?: string; couponId?: string;
priceId: string; priceId: string;
}) { }) {
return this.http.post('/api/subscription/stripe/checkout-session', { return this.http.post('/api/v1/subscription/stripe/checkout-session', {
couponId, couponId,
priceId priceId
}); });
} }
public fetchAccounts() { public fetchAccounts() {
return this.http.get<Accounts>('/api/account'); return this.http.get<Accounts>('/api/v1/account');
} }
public fetchAdminData() { public fetchAdminData() {
return this.http.get<AdminData>('/api/admin'); return this.http.get<AdminData>('/api/v1/admin');
} }
public fetchAdminMarketData() { public fetchAdminMarketData() {
return this.http.get<AdminMarketData>('/api/admin/market-data'); return this.http.get<AdminMarketData>('/api/v1/admin/market-data');
} }
public deleteAccess(aId: string) { public deleteAccess(aId: string) {
return this.http.delete<any>(`/api/access/${aId}`); return this.http.delete<any>(`/api/v1/access/${aId}`);
} }
public deleteAccount(aId: string) { public deleteAccount(aId: string) {
return this.http.delete<any>(`/api/account/${aId}`); return this.http.delete<any>(`/api/v1/account/${aId}`);
} }
public deleteOrder(aId: string) { public deleteOrder(aId: string) {
return this.http.delete<any>(`/api/order/${aId}`); return this.http.delete<any>(`/api/v1/order/${aId}`);
} }
public deleteUser(aId: string) { public deleteUser(aId: string) {
return this.http.delete<any>(`/api/user/${aId}`); return this.http.delete<any>(`/api/v1/user/${aId}`);
} }
public fetchAccesses() { public fetchAccesses() {
return this.http.get<Access[]>('/api/access'); return this.http.get<Access[]>('/api/v1/access');
} }
public fetchChart({ range }: { range: DateRange }) { public fetchChart({ range }: { range: DateRange }) {
return this.http.get<PortfolioChart>('/api/portfolio/chart', { return this.http.get<PortfolioChart>('/api/v1/portfolio/chart', {
params: { range } params: { range }
}); });
} }
@ -103,7 +101,7 @@ export class DataService {
params = params.append('activityIds', activityIds.join(',')); params = params.append('activityIds', activityIds.join(','));
} }
return this.http.get<Export>('/api/export', { return this.http.get<Export>('/api/v1/export', {
params params
}); });
} }
@ -121,7 +119,7 @@ export class DataService {
} }
public fetchInvestments(): Observable<PortfolioInvestments> { public fetchInvestments(): Observable<PortfolioInvestments> {
return this.http.get<any>('/api/portfolio/investments').pipe( return this.http.get<any>('/api/v1/portfolio/investments').pipe(
map((response) => { map((response) => {
if (response.firstOrderDate) { if (response.firstOrderDate) {
response.firstOrderDate = parseISO(response.firstOrderDate); response.firstOrderDate = parseISO(response.firstOrderDate);
@ -147,7 +145,7 @@ export class DataService {
params = params.append('includeHistoricalData', includeHistoricalData); params = params.append('includeHistoricalData', includeHistoricalData);
} }
return this.http.get<SymbolItem>(`/api/symbol/${dataSource}/${symbol}`, { return this.http.get<SymbolItem>(`/api/v1/symbol/${dataSource}/${symbol}`, {
params params
}); });
} }
@ -157,14 +155,14 @@ export class DataService {
}: { }: {
range: DateRange; range: DateRange;
}): Observable<PortfolioPositions> { }): Observable<PortfolioPositions> {
return this.http.get<PortfolioPositions>('/api/portfolio/positions', { return this.http.get<PortfolioPositions>('/api/v1/portfolio/positions', {
params: { range } params: { range }
}); });
} }
public fetchSymbols(aQuery: string) { public fetchSymbols(aQuery: string) {
return this.http return this.http
.get<{ items: LookupItem[] }>(`/api/symbol/lookup?query=${aQuery}`) .get<{ items: LookupItem[] }>(`/api/v1/symbol/lookup?query=${aQuery}`)
.pipe( .pipe(
map((respose) => { map((respose) => {
return respose.items; return respose.items;
@ -173,7 +171,7 @@ export class DataService {
} }
public fetchOrders(): Observable<Activities> { public fetchOrders(): Observable<Activities> {
return this.http.get<any>('/api/order').pipe( return this.http.get<any>('/api/v1/order').pipe(
map(({ activities }) => { map(({ activities }) => {
for (const activity of activities) { for (const activity of activities) {
activity.createdAt = parseISO(activity.createdAt); activity.createdAt = parseISO(activity.createdAt);
@ -185,14 +183,14 @@ export class DataService {
} }
public fetchPortfolioDetails(aParams: { [param: string]: any }) { public fetchPortfolioDetails(aParams: { [param: string]: any }) {
return this.http.get<PortfolioDetails>('/api/portfolio/details', { return this.http.get<PortfolioDetails>('/api/v1/portfolio/details', {
params: aParams params: aParams
}); });
} }
public fetchPortfolioPerformance(params: { [param: string]: any }) { public fetchPortfolioPerformance(params: { [param: string]: any }) {
return this.http.get<PortfolioPerformanceResponse>( return this.http.get<PortfolioPerformanceResponse>(
'/api/portfolio/performance', '/api/v1/portfolio/performance',
{ {
params params
} }
@ -201,16 +199,16 @@ export class DataService {
public fetchPortfolioPublic(aId: string) { public fetchPortfolioPublic(aId: string) {
return this.http.get<PortfolioPublicDetails>( return this.http.get<PortfolioPublicDetails>(
`/api/portfolio/public/${aId}` `/api/v1/portfolio/public/${aId}`
); );
} }
public fetchPortfolioReport() { public fetchPortfolioReport() {
return this.http.get<PortfolioReport>('/api/portfolio/report'); return this.http.get<PortfolioReport>('/api/v1/portfolio/report');
} }
public fetchPortfolioSummary(): Observable<PortfolioSummary> { public fetchPortfolioSummary(): Observable<PortfolioSummary> {
return this.http.get<any>('/api/portfolio/summary').pipe( return this.http.get<any>('/api/v1/portfolio/summary').pipe(
map((summary) => { map((summary) => {
if (summary.firstOrderDate) { if (summary.firstOrderDate) {
summary.firstOrderDate = parseISO(summary.firstOrderDate); summary.firstOrderDate = parseISO(summary.firstOrderDate);
@ -229,7 +227,7 @@ export class DataService {
symbol: string; symbol: string;
}) { }) {
return this.http return this.http
.get<any>(`/api/portfolio/position/${dataSource}/${symbol}`) .get<any>(`/api/v1/portfolio/position/${dataSource}/${symbol}`)
.pipe( .pipe(
map((data) => { map((data) => {
if (data.orders) { if (data.orders) {
@ -245,47 +243,47 @@ export class DataService {
} }
public loginAnonymous(accessToken: string) { public loginAnonymous(accessToken: string) {
return this.http.get<any>(`/api/auth/anonymous/${accessToken}`); return this.http.get<any>(`/api/v1/auth/anonymous/${accessToken}`);
} }
public postAccess(aAccess: CreateAccessDto) { public postAccess(aAccess: CreateAccessDto) {
return this.http.post<OrderModel>(`/api/access`, aAccess); return this.http.post<OrderModel>(`/api/v1/access`, aAccess);
} }
public postAccount(aAccount: CreateAccountDto) { public postAccount(aAccount: CreateAccountDto) {
return this.http.post<OrderModel>(`/api/account`, aAccount); return this.http.post<OrderModel>(`/api/v1/account`, aAccount);
} }
public postOrder(aOrder: CreateOrderDto) { public postOrder(aOrder: CreateOrderDto) {
return this.http.post<OrderModel>(`/api/order`, aOrder); return this.http.post<OrderModel>(`/api/v1/order`, aOrder);
} }
public postUser() { public postUser() {
return this.http.post<UserItem>(`/api/user`, {}); return this.http.post<UserItem>(`/api/v1/user`, {});
} }
public putAccount(aAccount: UpdateAccountDto) { public putAccount(aAccount: UpdateAccountDto) {
return this.http.put<UserItem>(`/api/account/${aAccount.id}`, aAccount); return this.http.put<UserItem>(`/api/v1/account/${aAccount.id}`, aAccount);
} }
public putAdminSetting(key: string, aData: PropertyDto) { public putAdminSetting(key: string, aData: PropertyDto) {
return this.http.put<void>(`/api/admin/settings/${key}`, aData); return this.http.put<void>(`/api/v1/admin/settings/${key}`, aData);
} }
public putOrder(aOrder: UpdateOrderDto) { public putOrder(aOrder: UpdateOrderDto) {
return this.http.put<UserItem>(`/api/order/${aOrder.id}`, aOrder); return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
} }
public putUserSetting(aData: UpdateUserSettingDto) { public putUserSetting(aData: UpdateUserSettingDto) {
return this.http.put<User>(`/api/user/setting`, aData); return this.http.put<User>(`/api/v1/user/setting`, aData);
} }
public putUserSettings(aData: UpdateUserSettingsDto) { public putUserSettings(aData: UpdateUserSettingsDto) {
return this.http.put<User>(`/api/user/settings`, aData); return this.http.put<User>(`/api/v1/user/settings`, aData);
} }
public redeemCoupon(couponCode: string) { public redeemCoupon(couponCode: string) {
return this.http.post('/api/subscription/redeem-coupon', { return this.http.post('/api/v1/subscription/redeem-coupon', {
couponCode couponCode
}); });
} }

View File

@ -37,9 +37,9 @@ export class ImportTransactionsService {
skipEmptyLines: true skipEmptyLines: true
}).data; }).data;
const orders: CreateOrderDto[] = []; const activities: CreateOrderDto[] = [];
for (const [index, item] of content.entries()) { for (const [index, item] of content.entries()) {
orders.push({ activities.push({
accountId: this.parseAccount({ item, userAccounts }), accountId: this.parseAccount({ item, userAccounts }),
currency: this.parseCurrency({ content, index, item }), currency: this.parseCurrency({ content, index, item }),
dataSource: this.parseDataSource({ item }), dataSource: this.parseDataSource({ item }),
@ -52,13 +52,13 @@ export class ImportTransactionsService {
}); });
} }
await this.importJson({ content: orders }); await this.importJson({ content: activities });
} }
public importJson({ content }: { content: CreateOrderDto[] }): Promise<void> { public importJson({ content }: { content: CreateOrderDto[] }): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.postImport({ this.postImport({
orders: content activities: content
}) })
.pipe( .pipe(
catchError((error) => { catchError((error) => {
@ -121,7 +121,10 @@ export class ImportTransactionsService {
} }
} }
throw { message: `orders.${index}.currency is not valid`, orders: content }; throw {
activities: content,
message: `activities.${index}.currency is not valid`
};
} }
private parseDataSource({ item }: { item: any }) { private parseDataSource({ item }: { item: any }) {
@ -164,7 +167,10 @@ export class ImportTransactionsService {
} }
} }
throw { message: `orders.${index}.date is not valid`, orders: content }; throw {
activities: content,
message: `activities.${index}.date is not valid`
};
} }
private parseFee({ private parseFee({
@ -184,7 +190,10 @@ export class ImportTransactionsService {
} }
} }
throw { message: `orders.${index}.fee is not valid`, orders: content }; throw {
activities: content,
message: `activities.${index}.fee is not valid`
};
} }
private parseQuantity({ private parseQuantity({
@ -204,7 +213,10 @@ export class ImportTransactionsService {
} }
} }
throw { message: `orders.${index}.quantity is not valid`, orders: content }; throw {
activities: content,
message: `activities.${index}.quantity is not valid`
};
} }
private parseSymbol({ private parseSymbol({
@ -224,7 +236,10 @@ export class ImportTransactionsService {
} }
} }
throw { message: `orders.${index}.symbol is not valid`, orders: content }; throw {
activities: content,
message: `activities.${index}.symbol is not valid`
};
} }
private parseType({ private parseType({
@ -255,7 +270,10 @@ export class ImportTransactionsService {
} }
} }
throw { message: `orders.${index}.type is not valid`, orders: content }; throw {
activities: content,
message: `activities.${index}.type is not valid`
};
} }
private parseUnitPrice({ private parseUnitPrice({
@ -276,12 +294,12 @@ export class ImportTransactionsService {
} }
throw { throw {
message: `orders.${index}.unitPrice is not valid`, activities: content,
orders: content message: `activities.${index}.unitPrice is not valid`
}; };
} }
private postImport(aImportData: { orders: CreateOrderDto[] }) { private postImport(aImportData: { activities: CreateOrderDto[] }) {
return this.http.post<void>('/api/import', aImportData); return this.http.post<void>('/api/v1/import', aImportData);
} }
} }

View File

@ -36,7 +36,7 @@ export class UserService extends ObservableStore<UserStoreState> {
} }
private fetchUser() { private fetchUser() {
return this.http.get<User>('/api/user').pipe( return this.http.get<User>('/api/v1/user').pipe(
map((user) => { map((user) => {
this.setState({ user }, UserStoreActions.GetUser); this.setState({ user }, UserStoreActions.GetUser);
return user; return user;

View File

@ -35,7 +35,7 @@ export class WebAuthnService {
public register() { public register() {
return this.http return this.http
.get<PublicKeyCredentialCreationOptionsJSON>( .get<PublicKeyCredentialCreationOptionsJSON>(
`/api/auth/webauthn/generate-registration-options`, `/api/v1/auth/webauthn/generate-registration-options`,
{} {}
) )
.pipe( .pipe(
@ -48,7 +48,7 @@ export class WebAuthnService {
}), }),
switchMap((attResp) => { switchMap((attResp) => {
return this.http.post<AuthDeviceDto>( return this.http.post<AuthDeviceDto>(
`/api/auth/webauthn/verify-attestation`, `/api/v1/auth/webauthn/verify-attestation`,
{ {
credential: attResp credential: attResp
} }
@ -65,7 +65,9 @@ export class WebAuthnService {
public deregister() { public deregister() {
const deviceId = this.getDeviceId(); const deviceId = this.getDeviceId();
return this.http.delete<AuthDeviceDto>(`/api/auth-device/${deviceId}`).pipe( return this.http
.delete<AuthDeviceDto>(`/api/v1/auth-device/${deviceId}`)
.pipe(
catchError((error) => { catchError((error) => {
console.warn(`Could not deregister device ${deviceId}`, error); console.warn(`Could not deregister device ${deviceId}`, error);
return of(null); return of(null);
@ -82,14 +84,14 @@ export class WebAuthnService {
const deviceId = this.getDeviceId(); const deviceId = this.getDeviceId();
return this.http return this.http
.post<PublicKeyCredentialRequestOptionsJSON>( .post<PublicKeyCredentialRequestOptionsJSON>(
`/api/auth/webauthn/generate-assertion-options`, `/api/v1/auth/webauthn/generate-assertion-options`,
{ deviceId } { deviceId }
) )
.pipe( .pipe(
switchMap(startAuthentication), switchMap(startAuthentication),
switchMap((assertionResponse) => { switchMap((assertionResponse) => {
return this.http.post<{ authToken: string }>( return this.http.post<{ authToken: string }>(
`/api/auth/webauthn/verify-assertion`, `/api/v1/auth/webauthn/verify-assertion`,
{ {
credential: assertionResponse, credential: assertionResponse,
deviceId deviceId

View File

@ -1,6 +1,7 @@
import { enableProdMode } from '@angular/core'; import { enableProdMode } from '@angular/core';
import { LOCALE_ID } from '@angular/core'; import { LOCALE_ID } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { locale } from '@ghostfolio/common/config';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -8,7 +9,7 @@ import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
(async () => { (async () => {
const response = await fetch('/api/info'); const response = await fetch('/api/v1/info');
const info: InfoItem = await response.json(); const info: InfoItem = await response.json();
if (window.localStorage.getItem('utm_source') === 'trusted-web-activity') { if (window.localStorage.getItem('utm_source') === 'trusted-web-activity') {
@ -27,7 +28,7 @@ import { environment } from './environments/environment';
platformBrowserDynamic() platformBrowserDynamic()
.bootstrapModule(AppModule, { .bootstrapModule(AppModule, {
providers: [{ provide: LOCALE_ID, useValue: 'de-CH' }] providers: [{ provide: LOCALE_ID, useValue: locale }]
}) })
.catch((error) => console.error(error)); .catch((error) => console.error(error));
})(); })();

View File

@ -11,20 +11,22 @@
.mat-row { .mat-row {
&:nth-child(even) { &:nth-child(even) {
background-color: rgba( background-color: rgba(var(--palette-foreground-base), 0.02);
var(--palette-foreground-base), }
var(--palette-background-hover-alpha)
); &:hover {
background-color: rgba(var(--palette-foreground-base), 0.05);
} }
} }
@if $darkTheme { @if $darkTheme {
.mat-row { .mat-row {
&:nth-child(even) { &:nth-child(even) {
background-color: rgba( background-color: rgba(var(--palette-foreground-base-dark), 0.02);
var(--palette-foreground-base-dark), }
var(--palette-background-hover-alpha)
); &:hover {
background-color: rgba(var(--palette-foreground-base-dark), 0.05);
} }
} }
} }

View File

@ -1,7 +1,7 @@
version: '3.7' version: '3.7'
services: services:
ghostfolio: ghostfolio:
image: ghostfolio/ghostfolio image: ghostfolio/ghostfolio:latest
env_file: env_file:
- ../.env - ../.env
environment: environment:

View File

@ -19,7 +19,7 @@ export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`;
export const ghostfolioFearAndGreedIndexDataSource = DataSource.RAKUTEN; export const ghostfolioFearAndGreedIndexDataSource = DataSource.RAKUTEN;
export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`; export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`;
export const locale = 'de-CH'; export const locale = 'en-US';
export const primaryColorHex = '#36cfcc'; export const primaryColorHex = '#36cfcc';
export const primaryColorRgb = { export const primaryColorRgb = {
@ -44,7 +44,6 @@ export const warnColorRgb = {
export const ASSET_SUB_CLASS_EMERGENCY_FUND = 'EMERGENCY_FUND'; export const ASSET_SUB_CLASS_EMERGENCY_FUND = 'EMERGENCY_FUND';
export const DEFAULT_DATE_FORMAT = 'dd.MM.yyyy';
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const PROPERTY_COUPONS = 'COUPONS'; export const PROPERTY_COUPONS = 'COUPONS';

View File

@ -2,7 +2,7 @@ import * as currencies from '@dinero.js/currencies';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { getDate, getMonth, getYear, parse, subDays } from 'date-fns'; import { getDate, getMonth, getYear, parse, subDays } from 'date-fns';
import { ghostfolioScraperApiSymbolPrefix } from './config'; import { ghostfolioScraperApiSymbolPrefix, locale } from './config';
export function capitalize(aString: string) { export function capitalize(aString: string) {
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase(); return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
@ -44,6 +44,49 @@ export function getCssVariable(aCssVariable: string) {
); );
} }
export function getDateFormatString(aLocale?: string) {
const formatObject = new Intl.DateTimeFormat(aLocale).formatToParts(
new Date()
);
return formatObject
.map((object) => {
switch (object.type) {
case 'day':
return 'dd';
case 'month':
return 'MM';
case 'year':
return 'yyyy';
default:
return object.value;
}
})
.join('');
}
export function getLocale() {
return navigator.languages?.length
? navigator.languages[0]
: navigator.language ?? locale;
}
export function getNumberFormatDecimal(aLocale?: string) {
const formatObject = new Intl.NumberFormat(aLocale).formatToParts(9999.99);
return formatObject.find((object) => {
return object.type === 'decimal';
}).value;
}
export function getNumberFormatGroup(aLocale?: string) {
const formatObject = new Intl.NumberFormat(aLocale).formatToParts(9999.99);
return formatObject.find((object) => {
return object.type === 'group';
}).value;
}
export function getTextColor() { export function getTextColor() {
const cssVariable = getCssVariable( const cssVariable = getCssVariable(
window.matchMedia('(prefers-color-scheme: dark)').matches window.matchMedia('(prefers-color-scheme: dark)').matches

View File

@ -5,5 +5,5 @@ export interface Export {
date: string; date: string;
version: string; version: string;
}; };
orders: Partial<Order>[]; activities: Partial<Order>[];
} }

View File

@ -1,6 +1,7 @@
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Market } from '../types';
import { Country } from './country.interface'; import { Country } from './country.interface';
import { Sector } from './sector.interface'; import { Sector } from './sector.interface';
@ -19,6 +20,7 @@ export interface PortfolioPosition {
marketChange?: number; marketChange?: number;
marketChangePercent?: number; marketChangePercent?: number;
marketPrice: number; marketPrice: number;
markets?: { [key in Market]: number };
marketState: MarketState; marketState: MarketState;
name: string; name: string;
netPerformance: number; netPerformance: number;

View File

@ -8,6 +8,7 @@ export interface PortfolioPublicDetails {
| 'allocationCurrent' | 'allocationCurrent'
| 'countries' | 'countries'
| 'currency' | 'currency'
| 'markets'
| 'name' | 'name'
| 'sectors' | 'sectors'
| 'value' | 'value'

View File

@ -2,6 +2,7 @@ import type { AccessWithGranteeUser } from './access-with-grantee-user.type';
import { AccountWithValue } from './account-with-value.type'; import { AccountWithValue } from './account-with-value.type';
import type { DateRange } from './date-range.type'; import type { DateRange } from './date-range.type';
import type { Granularity } from './granularity.type'; import type { Granularity } from './granularity.type';
import { Market } from './market.type';
import type { OrderWithAccount } from './order-with-account.type'; import type { OrderWithAccount } from './order-with-account.type';
import type { RequestWithUser } from './request-with-user.type'; import type { RequestWithUser } from './request-with-user.type';
import { ToggleOption } from './toggle-option.type'; import { ToggleOption } from './toggle-option.type';
@ -11,6 +12,7 @@ export type {
AccountWithValue, AccountWithValue,
DateRange, DateRange,
Granularity, Granularity,
Market,
OrderWithAccount, OrderWithAccount,
RequestWithUser, RequestWithUser,
ToggleOption ToggleOption

View File

@ -0,0 +1 @@
export type Market = 'developedMarkets' | 'emergingMarkets' | 'otherMarkets';

View File

@ -248,14 +248,18 @@
<ng-container matColumnDef="value"> <ng-container matColumnDef="value">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="justify-content-end px-1" class="d-none d-lg-table-cell justify-content-end px-1"
i18n i18n
mat-header-cell mat-header-cell
mat-sort-header mat-sort-header
> >
Value Value
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
[isCurrency]="true" [isCurrency]="true"
@ -264,7 +268,38 @@
></gf-value> ></gf-value>
</div> </div>
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell> <td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
<div class="d-flex justify-content-end">
<gf-value
[isAbsolute]="true"
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : totalValue"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="valueInBaseCurrency">
<th
*matHeaderCellDef
class="d-lg-none d-xl-none justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Value
</th>
<td *matCellDef="let element" class="d-lg-none d-xl-none px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.valueInBaseCurrency"
></gf-value>
</div>
</td>
<td *matFooterCellDef class="d-lg-none d-xl-none px-1" mat-footer-cell>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
[isAbsolute]="true" [isAbsolute]="true"

View File

@ -20,10 +20,9 @@ import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { endOfToday, format, isAfter } from 'date-fns'; import { endOfToday, format, isAfter } from 'date-fns';
@ -64,7 +63,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<Activity> = new MatTableDataSource(); public dataSource: MatTableDataSource<Activity> = new MatTableDataSource();
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat: string;
public displayedColumns = []; public displayedColumns = [];
public endOfToday = endOfToday(); public endOfToday = endOfToday();
public filters$: Subject<string[]> = new BehaviorSubject([]); public filters$: Subject<string[]> = new BehaviorSubject([]);
@ -141,6 +140,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
'fee', 'fee',
'value', 'value',
'currency', 'currency',
'valueInBaseCurrency',
'account', 'account',
'actions' 'actions'
]; ];
@ -153,6 +153,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
this.isLoading = true; this.isLoading = true;
this.defaultDateFormat = getDateFormatString(this.locale);
if (this.activities) { if (this.activities) {
this.dataSource = new MatTableDataSource(this.activities); this.dataSource = new MatTableDataSource(this.activities);
this.dataSource.filterPredicate = (data, filter) => { this.dataSource.filterPredicate = (data, filter) => {

View File

@ -43,10 +43,15 @@
</div> </div>
</ng-container> </ng-container>
</div> </div>
<small *ngIf="label"> <ng-container *ngIf="label">
<div *ngIf="size === 'large'">
{{ label }}
</div>
<small *ngIf="size !== 'large'">
{{ label }} {{ label }}
</small> </small>
</ng-container> </ng-container>
</ng-container>
<ngx-skeleton-loader <ngx-skeleton-loader
*ngIf="value === undefined" *ngIf="value === undefined"

View File

@ -4,8 +4,8 @@ import {
Input, Input,
OnChanges OnChanges
} from '@angular/core'; } from '@angular/core';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { getLocale } from '@ghostfolio/common/helper';
import { format, isDate, parseISO } from 'date-fns'; import { isDate, parseISO } from 'date-fns';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@Component({ @Component({
@ -21,7 +21,7 @@ export class ValueComponent implements OnChanges {
@Input() isCurrency = false; @Input() isCurrency = false;
@Input() isPercent = false; @Input() isPercent = false;
@Input() label = ''; @Input() label = '';
@Input() locale = ''; @Input() locale = getLocale();
@Input() position = ''; @Input() position = '';
@Input() precision: number | undefined; @Input() precision: number | undefined;
@Input() size: 'large' | 'medium' | 'small' = 'small'; @Input() size: 'large' | 'medium' | 'small' = 'small';
@ -102,10 +102,13 @@ export class ValueComponent implements OnChanges {
try { try {
if (isDate(parseISO(this.value))) { if (isDate(parseISO(this.value))) {
this.formattedValue = format( this.formattedValue = new Date(
new Date(<string>this.value), <string>this.value
DEFAULT_DATE_FORMAT ).toLocaleDateString(this.locale, {
); day: '2-digit',
month: '2-digit',
year: 'numeric'
});
} }
} catch { } catch {
this.formattedValue = this.value; this.formattedValue = this.value;
@ -116,5 +119,9 @@ export class ValueComponent implements OnChanges {
if (this.formattedValue === '0.00') { if (this.formattedValue === '0.00') {
this.useAbsoluteValue = true; this.useAbsoluteValue = true;
} }
if (this.isPercent) {
this.formattedValue = ' ';
}
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "1.127.0", "version": "1.132.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
@ -71,7 +71,7 @@
"@nestjs/schedule": "1.0.2", "@nestjs/schedule": "1.0.2",
"@nestjs/serve-static": "2.2.2", "@nestjs/serve-static": "2.2.2",
"@nrwl/angular": "13.8.5", "@nrwl/angular": "13.8.5",
"@prisma/client": "3.10.0", "@prisma/client": "3.11.1",
"@simplewebauthn/browser": "4.1.0", "@simplewebauthn/browser": "4.1.0",
"@simplewebauthn/server": "4.1.0", "@simplewebauthn/server": "4.1.0",
"@simplewebauthn/typescript-types": "4.0.0", "@simplewebauthn/typescript-types": "4.0.0",
@ -109,7 +109,7 @@
"passport": "0.4.1", "passport": "0.4.1",
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0", "passport-jwt": "4.0.0",
"prisma": "3.10.0", "prisma": "3.11.1",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"round-to": "5.0.0", "round-to": "5.0.0",
"rxjs": "7.4.0", "rxjs": "7.4.0",
@ -118,7 +118,7 @@
"tslib": "2.0.0", "tslib": "2.0.0",
"twitter-api-v2": "1.10.3", "twitter-api-v2": "1.10.3",
"uuid": "8.3.2", "uuid": "8.3.2",
"yahoo-finance2": "2.2.0", "yahoo-finance2": "2.3.0",
"zone.js": "0.11.4" "zone.js": "0.11.4"
}, },
"devDependencies": { "devDependencies": {

View File

@ -3,7 +3,7 @@
"date": "2021-01-01T00:00:00.000Z", "date": "2021-01-01T00:00:00.000Z",
"version": "dev" "version": "dev"
}, },
"orders": [ "activities": [
{ {
"currency": "USD", "currency": "USD",
"dataSource": "YAHOO", "dataSource": "YAHOO",

View File

@ -3,7 +3,7 @@
"date": "2021-01-01T00:00:00.000Z", "date": "2021-01-01T00:00:00.000Z",
"version": "dev" "version": "dev"
}, },
"orders": [ "activities": [
{ {
"currency": "USD", "currency": "USD",
"dataSource": "YAHOO", "dataSource": "YAHOO",

39
test/import/ok.json Normal file
View File

@ -0,0 +1,39 @@
{
"meta": {
"date": "2022-04-01T00:00:00.000Z",
"version": "dev"
},
"activities": [
{
"accountId": null,
"date": "2021-12-31T23:00:00.000Z",
"fee": 0,
"quantity": 1,
"type": "ITEM",
"unitPrice": 500000,
"currency": "USD",
"dataSource": "MANUAL",
"symbol": "Penthouse Apartment"
},
{
"date": "2021-11-16T23:00:00.000Z",
"fee": 0,
"quantity": 5,
"type": "DIVIDEND",
"unitPrice": 0.62,
"currency": "USD",
"dataSource": "YAHOO",
"symbol": "MSFT"
},
{
"date": "2021-09-15T22:00:00.000Z",
"fee": 19,
"quantity": 5,
"type": "BUY",
"unitPrice": 298.58,
"currency": "USD",
"dataSource": "YAHOO",
"symbol": "MSFT"
}
]
}

View File

@ -3487,22 +3487,22 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be"
integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw== integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw==
"@prisma/client@3.10.0": "@prisma/client@3.11.1":
version "3.10.0" version "3.11.1"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.10.0.tgz#4782fe6f1b0e43c2a11a75ad4bb1098599d1dfb1" resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.11.1.tgz#bde6dec71ae133d04ce1c6658e3d76627a3c6dc7"
integrity sha512-6P4sV7WFuODSfSoSEzCH1qfmWMrCUBk1LIIuTbQf6m1LI/IOpLN4lnqGDmgiBGprEzuWobnGLfe9YsXLn0inrg== integrity sha512-B3C7zQG4HbjJzUr2Zg9UVkBJutbqq9/uqkl1S138+keZCubJrwizx3RuIvGwI+s+pm3qbsyNqXiZgL3Ir0fSng==
dependencies: dependencies:
"@prisma/engines-version" "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86" "@prisma/engines-version" "3.11.1-1.1a2506facaf1a4727b7c26850735e88ec779dee9"
"@prisma/engines-version@3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86": "@prisma/engines-version@3.11.1-1.1a2506facaf1a4727b7c26850735e88ec779dee9":
version "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86" version "3.11.1-1.1a2506facaf1a4727b7c26850735e88ec779dee9"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86.tgz#82750856fa637dd89b8f095d2dcc6ac0631231c6" resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.11.1-1.1a2506facaf1a4727b7c26850735e88ec779dee9.tgz#81a1835b495ad287ad7824dbd62f74e9eee90fb9"
integrity sha512-cVYs5gyQH/qyut24hUvDznCfPrWiNMKNfPb9WmEoiU6ihlkscIbCfkmuKTtspVLWRdl0LqjYEC7vfnPv17HWhw== integrity sha512-HkcsDniA4iNb/gi0iuyOJNAM7nD/LwQ0uJm15v360O5dee3TM4lWdSQiTYBMK6FF68ACUItmzSur7oYuUZ2zkQ==
"@prisma/engines@3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86": "@prisma/engines@3.11.1-1.1a2506facaf1a4727b7c26850735e88ec779dee9":
version "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86" version "3.11.1-1.1a2506facaf1a4727b7c26850735e88ec779dee9"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86.tgz#2964113729a78b8b21e186b5592affd1fde73c16" resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.11.1-1.1a2506facaf1a4727b7c26850735e88ec779dee9.tgz#09ac23f8f615a8586d8d44538060ada199fe872c"
integrity sha512-LjRssaWu9w2SrXitofnutRIyURI7l0veQYIALz7uY4shygM9nMcK3omXcObRm7TAcw3Z+9ytfK1B+ySOsOesxQ== integrity sha512-MILbsGnvmnhCbFGa2/iSnsyGyazU3afzD7ldjCIeLIGKkNBMSZgA2IvpYsAXl+6qFHKGrS3B2otKfV31dwMSQw==
"@samverschueren/stream-to-observable@^0.3.0": "@samverschueren/stream-to-observable@^0.3.0":
version "0.3.1" version "0.3.1"
@ -15334,12 +15334,12 @@ pretty-hrtime@^1.0.3:
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE= integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=
prisma@3.10.0: prisma@3.11.1:
version "3.10.0" version "3.11.1"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.10.0.tgz#872d87afbeb1cbcaa77c3d6a63c125e0d704b04d" resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.11.1.tgz#fff9c0bcf83cb30c2e1d650882d5eb3c5565e028"
integrity sha512-dAld12vtwdz9Rz01nOjmnXe+vHana5PSog8t0XGgLemKsUVsaupYpr74AHaS3s78SaTS5s2HOghnJF+jn91ZrA== integrity sha512-aYn8bQwt1xwR2oSsVNHT4PXU7EhsThIwmpNB/MNUaaMx5OPLTro6VdNJe/sJssXFLxhamfWeMjwmpXjljo6xkg==
dependencies: dependencies:
"@prisma/engines" "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86" "@prisma/engines" "3.11.1-1.1a2506facaf1a4727b7c26850735e88ec779dee9"
prismjs@^1.21.0, prismjs@~1.24.0: prismjs@^1.21.0, prismjs@~1.24.0:
version "1.24.1" version "1.24.1"
@ -18836,10 +18836,10 @@ y18n@^5.0.5:
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yahoo-finance2@2.2.0: yahoo-finance2@2.3.0:
version "2.2.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.2.0.tgz#8694b04e69f4a79996812b6d082e5b738c51cee6" resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.3.0.tgz#81bd76732dfd38aa5d7019a97caf0f938c0127c2"
integrity sha512-ZxLCcoh+J51F7Tol1jpVBmy50IBQSoxsECWYDToBxjZwPloFNHtEVOXNqJlyzTysnzVbPA5TeCNT6G0DoaJnNQ== integrity sha512-7oj8n/WJH9MtX+q99WbHdjEVPdobTX8IyYjg7v4sDOh4f9ByT2Frxmp+Uj+rctrO0EiiD9QWTuwV4h8AemGuCg==
dependencies: dependencies:
ajv "8.10.0" ajv "8.10.0"
ajv-formats "2.1.1" ajv-formats "2.1.1"