Compare commits

...

47 Commits

Author SHA1 Message Date
91c748c7ad Release 1.25.0 (#210) 2021-07-11 17:21:24 +02:00
ecfe694f0b Feature/export transactions (#209)
* Export functionality for transactions

* Update changelog
2021-07-11 17:05:58 +02:00
1491bf7f76 Update changelog (#208) 2021-07-11 10:37:02 +02:00
b3b9a051c3 Shorten slogan (#207) 2021-07-11 10:31:36 +02:00
bf1146bfd6 Feature/change slogan to wealth management (#206)
* Harmonize slogans to "Open Source Wealth Management Software"

* Update changelog
2021-07-10 19:20:02 +02:00
0774ca91a1 Improve settings selectors layout (#205) 2021-07-10 18:17:17 +02:00
f403807f2d Bugfix/fix average buy price calculation (#204)
* Fix average buy price calculation

* Update changelog
2021-07-10 18:16:46 +02:00
f22991b090 Feature/respect cash balance in analysis (#203)
* Respect cash balance in in analysis

* Update changelog
2021-07-10 14:57:03 +02:00
1135a5b335 Fix rendering of currency and platform in dialogs and clean up observables (#202) 2021-07-08 21:28:28 +02:00
d9ea255c17 Release 1.24.0 (#201) 2021-07-07 21:45:57 +02:00
2c19d8c8e7 Feature/add balance to account (#193)
* Add balance attribute and calculate total balance

* Update changelog
2021-07-07 21:23:36 +02:00
db090229ce Feature/add total value in the create or edit transaction dialog (#192)
* Display total value

* Update changelog
2021-07-07 21:14:01 +02:00
fbe590ddb9 Feature/upgrade angular material css vars to 2.0.0 (#200)
* Upgrade angular-material-css-vars

* Update changelog
2021-07-05 21:53:30 +02:00
0d65136a9e Revert "Remove unneeded dependencies (#197)" (#199)
This reverts commit a062a3cee4.
2021-07-05 20:35:10 +02:00
dea87cc3cf Improve README.md (#198) 2021-07-04 22:19:09 +02:00
a062a3cee4 Remove unneeded dependencies (#197) 2021-07-04 22:11:47 +02:00
5b1b207a6f Feature/upgrade angular dependencies to version 12.0.x (#196)
* Update angular dependencies to version 12.0.X

* Update changelog
2021-07-04 21:55:25 +02:00
63cc7b2871 Feature/upgrade nestjs dependencies (#195)
* Upgrade @nestjs dependencies

* Update changelog
2021-07-04 21:45:53 +02:00
3986e8f879 Upgrade Nx to version 12.5.4 (#194) 2021-07-04 21:31:15 +02:00
290e93bbd7 Release 1.23.1 (#191) 2021-07-03 12:20:36 +02:00
b08ecd1b18 Release 1.23.0 (#190) 2021-07-03 11:51:26 +02:00
92d321a001 Drafts for orders (#187)
* Render the future with a dashed border

* Update changelog
2021-07-03 11:32:03 +02:00
ce2d8d519d Change from travis-ci.org to travis-ci.com (#188) 2021-06-30 21:52:29 +02:00
f32bef071e Add contributing section (#186) 2021-06-27 10:23:51 +02:00
4aa7365d9b Release 1.22.0 (#185) 2021-06-25 17:34:49 +02:00
367f25a975 Feature/set user id in stripe callback (#184)
* Set user id as description

* Update changelog
2021-06-24 21:52:41 +02:00
9832334da1 Move @types/lodash to dev dependencies (#183) 2021-06-23 17:36:40 +02:00
e126f9ec54 Release 1.21.0 (#182) 2021-06-22 21:55:00 +02:00
09bbda3502 Change from subscription to one time payment (#181) 2021-06-22 21:53:29 +02:00
ee9a521813 Bugfix/fix base currency in pricing page (#180)
* Fix base currency

* Update changelog
2021-06-21 20:52:01 +02:00
169c151547 Feature/improve style of about page (#177)
* Improve style

* Update changelog
2021-06-21 20:08:45 +02:00
3a95ec0f81 Release 1.20.0 (#179) 2021-06-21 20:05:54 +02:00
ad00cd9d81 Feature/setup subscription with stripe (#178)
* Set up stripe for subscriptions

* Update permissions and add discount

* Update changelog
2021-06-21 20:03:36 +02:00
373a2015c0 Release 1.19.0 (#176) 2021-06-17 23:01:59 +02:00
66c955ad6c Feature/ghostfolio in numbers (#175)
* Add Ghostfolio in numbers section

* Update changelog
2021-06-17 22:59:48 +02:00
a2440fc067 Release 1.18.0 (#174) 2021-06-16 17:34:43 +02:00
3d7624d997 Feature/improve twa onboarding (#173)
* Improve TWA onboarding (Redirect to the account registration page)

* Update changelog
2021-06-16 17:31:31 +02:00
0264b592b9 Feature/improve investments by sector (#172)
* Improve investments analysis by sector

* Update changelog
2021-06-16 17:05:43 +02:00
198eaf57d3 Release 1.17.0 (#171) 2021-06-15 21:17:41 +02:00
6783ea2ebb Feature/upgrade various frontend dependencies (#170)
* Upgrade frontend dependencies

* Update changelog
2021-06-15 21:15:48 +02:00
a35701fe24 Feature/upgrade to angular 12 (#169)
* Upgrade to Angular 12

* Update changelog
2021-06-15 21:03:55 +02:00
5db90f1787 Feature/improve error page of fingerprint sign in (#167)
* Improve error page

* Update changelog
2021-06-15 09:47:18 +02:00
81fe538484 Order attribute 2021-06-15 09:43:48 +02:00
51884913be Feature/disable fingerprint sign in in demo account page (#163)
* Disable fingerprint toggle for demo user

* Update changelog
2021-06-15 09:21:53 +02:00
8886082dfa Feature/upgrade eslint and prettier dependencies (#164)
* Upgrade eslint and prettier dependencies

* Feature/upgrade date fns to version 2.22.1 (#165)

* Feature/upgrade chart.js to version 3.3.2 (#166)

* Update changelog
2021-06-15 09:17:27 +02:00
3b12e5b85b Release 1.16.0 (#162) 2021-06-14 22:00:00 +02:00
6c1119caec Restrict webauthn to fingerprint only and improve UX (#161)
* Restrict webauthn to fingerprint only

* Move webauthn login to separate page /webauthn

* Stay signed in with social login

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-06-14 21:57:09 +02:00
137 changed files with 5362 additions and 3583 deletions

View File

@ -5,6 +5,109 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.25.0 - 11.07.2021
### Added
- Added the export functionality for transactions
### Changed
- Respected the cash balance on the analysis page
- Improved the settings selectors on the account page
- Harmonized the slogan to "Open Source Wealth Management Software"
### Fixed
- Fixed rendering of currency and platform in dialogs (account and transaction)
- Fixed an issue in the calculation of the average buy prices in the position detail chart
## 1.24.0 - 07.07.2021
### Added
- Added the total value in the create or edit transaction dialog
- Added a balance attribute to the account model
- Calculated the total balance (cash)
### Changed
- Upgraded `@angular/cdk` and `@angular/material` from version `11.0.4` to `12.0.6`
- Upgraded `@nestjs` dependencies
- Upgraded `angular-material-css-vars` from version `1.2.0` to `2.0.0`
- Upgraded `Nx` from version `12.3.6` to `12.5.4`
## 1.23.1 - 03.07.2021
### Fixed
- Fixed the investment chart (drafts)
## 1.23.0 - 03.07.2021
### Added
- Added support for future transactions (drafts)
## 1.22.0 - 25.06.2021
### Added
- Set the user id in the _Stripe_ callback
## 1.21.0 - 22.06.2021
### Changed
- Changed _Stripe_ mode from `subscription` to `payment`
### Fixed
- Fixed the base currency on the pricing page
## 1.20.0 - 21.06.2021
### Added
- Set up _Stripe_ for subscriptions
### Changed
- Improved the style of the _Ghostfolio in Numbers_ section
## 1.19.0 - 17.06.2021
### Added
- Added a _Ghostfolio in Numbers_ section to the about page
## 1.18.0 - 16.06.2021
### Changed
- Improved the pie chart: Investments by sector
- Improved the onboarding for TWA by redirecting to the account registration page
## 1.17.0 - 15.06.2021
### Changed
- Improved the error page of the sign in with fingerprint
- Disable the sign in with fingerprint selector for the demo user
- Upgraded `angular` from version `11.2.4` to `12.0.4`
- Upgraded `angular-material-css-vars` from version `1.1.2` to `1.2.0`
- Upgraded `chart.js` from version `3.2.1` to `3.3.2`
- Upgraded `date-fns` from version `2.19.0` to `2.22.1`
- Upgraded `eslint` and `prettier` dependencies
- Upgraded `ngx-device-detector` from version `2.0.6` to `2.1.1`
- Upgraded `ngx-markdown` from version `11.1.2` to `12.0.1`
## 1.16.0 - 14.06.2021
### Changed
- Improved the sign in with fingerprint
## 1.15.0 - 14.06.2021
### Added

View File

@ -1,21 +1,36 @@
<div align="center">
<a href="https://ghostfol.io">
<img
alt="Ghostfolio Logo"
src="https://avatars.githubusercontent.com/u/82473144?s=200"
width="100"
/>
</a>
<h1>Ghostfolio</h1>
<p>
<strong>Open Source Portfolio Tracker</strong>
<strong>Open Source Wealth Management Software made for Humans</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
</p>
<p>
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/>
<a href="https://travis-ci.org/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
<a href="#contributing">
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
</p>
</div>
**Ghostfolio** is an open source portfolio tracker based on web technology. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
## Ghostfolio Premium
Our official cloud offering [Ghostfolio Premium](https://ghostfol.io/pricing) is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
## Why Ghostfolio?
@ -79,12 +94,12 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
1. Start server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
1. Press _Sign out_ and check out the _Live Demo_
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_
## Development
Please make sure you have completed the instructions from [_Setup_](#Setup)
Please make sure you have completed the instructions from [_Setup_](#Setup).
### Start server
@ -101,6 +116,12 @@ Run `yarn start:client`
Run `yarn test`
## 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.
Not sure what to work on? We have got some ideas. Please tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
## License
© 2021 [Ghostfolio](https://ghostfol.io)

View File

@ -86,7 +86,6 @@
"main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json",
"aot": true,
"assets": [
"apps/client/src/assets",
{
@ -121,7 +120,13 @@
}
],
"styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/lib/marked.js"]
"scripts": ["node_modules/marked/lib/marked.js"],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"configurations": {
"production": {
@ -152,7 +157,8 @@
]
}
},
"outputs": ["{options.outputPath}"]
"outputs": ["{options.outputPath}"],
"defaultConfiguration": ""
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",

View File

@ -11,5 +11,6 @@ module.exports = {
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000
testTimeout: 10000,
testEnvironment: 'node'
};

View File

@ -4,6 +4,7 @@ import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alph
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
@ -20,6 +21,7 @@ import { AccountService } from './account.service';
AlphaVantageService,
ConfigurationService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
ImpersonationService,
PrismaService,

View File

@ -1,12 +1,15 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Account, Order, Prisma } from '@prisma/client';
import { Account, Currency, Order, Prisma } from '@prisma/client';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CashDetails } from './interfaces/cash-details.interface';
@Injectable()
export class AccountService {
public constructor(
private exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService,
private prisma: PrismaService
) {}
@ -73,6 +76,27 @@ export class AccountService {
});
}
public async getCashDetails(
aUserId: string,
aCurrency: Currency
): Promise<CashDetails> {
let totalCashBalance = 0;
const accounts = await this.accounts({
where: { userId: aUserId }
});
accounts.forEach((account) => {
totalCashBalance += this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
);
});
return { accounts, balance: totalCashBalance };
}
public async updateAccount(
params: {
where: Prisma.AccountWhereUniqueInput;

View File

@ -1,10 +1,16 @@
import { AccountType } from '@prisma/client';
import { IsString, ValidateIf } from 'class-validator';
import { AccountType, Currency } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateAccountDto {
@IsString()
accountType: AccountType;
@IsNumber()
balance: number;
@IsString()
currency: Currency;
@IsString()
name: string;

View File

@ -0,0 +1,6 @@
import { Account } from '@prisma/client';
export interface CashDetails {
accounts: Account[];
balance: number;
}

View File

@ -1,10 +1,16 @@
import { AccountType } from '@prisma/client';
import { IsString, ValidateIf } from 'class-validator';
import { AccountType, Currency } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class UpdateAccountDto {
@IsString()
accountType: AccountType;
@IsNumber()
balance: number;
@IsString()
currency: Currency;
@IsString()
id: string;

View File

@ -23,10 +23,12 @@ import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module';
import { ExperimentalModule } from './experimental/experimental.module';
import { ExportModule } from './export/export.module';
import { InfoModule } from './info/info.module';
import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module';
@ -40,6 +42,7 @@ import { UserModule } from './user/user.module';
CacheModule,
ConfigModule.forRoot(),
ExperimentalModule,
ExportModule,
InfoModule,
OrderModule,
PortfolioModule,
@ -59,6 +62,7 @@ import { UserModule } from './user/user.module';
rootPath: join(__dirname, '..', 'client'),
exclude: ['/api*']
}),
SubscriptionModule,
SymbolModule,
UserModule
],

View File

@ -57,8 +57,9 @@ export class WebAuthService {
timeout: 60000,
attestationType: 'indirect',
authenticatorSelection: {
userVerification: 'preferred',
requireResidentKey: false
authenticatorAttachment: 'platform',
requireResidentKey: false,
userVerification: 'required'
}
};
@ -143,7 +144,7 @@ export class WebAuthService {
{
id: device.credentialId,
type: 'public-key',
transports: ['usb', 'ble', 'nfc', 'internal']
transports: ['internal']
}
],
userVerification: 'preferred',

View File

@ -1,3 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
@ -13,9 +15,10 @@ import { ExperimentalController } from './experimental.controller';
import { ExperimentalService } from './experimental.service';
@Module({
imports: [],
imports: [RedisCacheModule],
controllers: [ExperimentalController],
providers: [
AccountService,
AlphaVantageService,
ConfigurationService,
DataProviderService,

View File

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
@ -14,6 +15,7 @@ import { Data } from './interfaces/data.interface';
@Injectable()
export class ExperimentalService {
public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private prisma: PrismaService,
@ -52,6 +54,7 @@ export class ExperimentalService {
});
const portfolio = new Portfolio(
this.accountService,
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService

View File

@ -0,0 +1,23 @@
import { Export } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { ExportService } from './export.service';
@Controller('export')
export class ExportController {
public constructor(
private readonly exportService: ExportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async export(): Promise<Export> {
return await this.exportService.export({
userId: this.request.user.id
});
}
}

View File

@ -0,0 +1,32 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [RedisCacheModule],
controllers: [ExportController],
providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
ExportService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class ExportModule {}

View File

@ -0,0 +1,31 @@
import { environment } from '@ghostfolio/api/environments/environment';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExportService {
public constructor(private prisma: PrismaService) {}
public async export({ userId }: { userId: string }): Promise<Export> {
const orders = await this.prisma.order.findMany({
orderBy: { date: 'desc' },
select: {
currency: true,
dataSource: true,
date: true,
fee: true,
quantity: true,
symbol: true,
type: true,
unitPrice: true
},
where: { userId }
});
return {
meta: { date: new Date().toISOString(), version: environment.version },
orders
};
}
}

View File

@ -1,10 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Currency } from '@prisma/client';
import * as bent from 'bent';
import { subDays } from 'date-fns';
@Injectable()
export class InfoService {
@ -28,6 +31,10 @@ export class InfoService {
globalPermissions.push(permissions.enableSocialLogin);
}
if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
globalPermissions.push(permissions.enableStatistics);
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
globalPermissions.push(permissions.enableSubscription);
}
@ -37,10 +44,59 @@ export class InfoService {
platforms,
currencies: Object.values(Currency),
demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering()
lastDataGathering: await this.getLastDataGathering(),
statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions()
};
}
private async countActiveUsers(aDays: number) {
return await this.prisma.user.count({
orderBy: {
Analytics: {
updatedAt: 'desc'
}
},
where: {
AND: [
{
NOT: {
Analytics: null
}
},
{
Analytics: {
updatedAt: {
gt: subDays(new Date(), aDays)
}
}
}
]
}
});
}
private async countGitHubStargazers(): Promise<number> {
try {
const get = bent(
`https://api.github.com/repos/ghostfolio/ghostfolio`,
'GET',
'json',
200,
{
'User-Agent': 'request'
}
);
const { stargazers_count } = await get();
return stargazers_count;
} catch (error) {
console.error(error);
return undefined;
}
}
private getDemoAuthToken() {
return this.jwtService.sign({
id: InfoService.DEMO_USER_ID
@ -54,4 +110,36 @@ export class InfoService {
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null;
}
private async getStatistics() {
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
return undefined;
}
const activeUsers1d = await this.countActiveUsers(1);
const activeUsers30d = await this.countActiveUsers(30);
const gitHubStargazers = await this.countGitHubStargazers();
return {
activeUsers1d,
activeUsers30d,
gitHubStargazers
};
}
private async getSubscriptions(): Promise<Subscription[]> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
const stripeConfig = await this.prisma.property.findUnique({
where: { key: 'STRIPE_CONFIG' }
});
if (stripeConfig) {
return [JSON.parse(stripeConfig.value)];
}
return [];
}
}

View File

@ -1,5 +1,5 @@
import { Currency, DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
import { IsISO8601, IsNumber, IsString } from 'class-validator';
export class CreateOrderDto {
@IsString()

View File

@ -68,10 +68,11 @@ export class OrderController {
public async getAllOrders(
@Headers('impersonation-id') impersonationId
): Promise<OrderModel[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let orders = await this.orderService.orders({
include: {

View File

@ -3,6 +3,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
import { CacheService } from '../cache/cache.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
@ -50,14 +51,16 @@ export class OrderService {
): Promise<Order> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
// Gather symbol data of order in the background
this.dataGatheringService.gatherSymbols([
{
dataSource: data.dataSource,
date: <Date>data.date,
symbol: data.symbol
}
]);
if (!isAfter(data.date as Date, endOfToday())) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.dataSource,
date: <Date>data.date,
symbol: data.symbol
}
]);
}
await this.cacheService.flush(aUserId);

View File

@ -142,10 +142,11 @@ export class PortfolioController {
): Promise<{ [symbol: string]: PortfolioPosition }> {
let details: { [symbol: string]: PortfolioPosition } = {};
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
@ -221,6 +222,7 @@ export class PortfolioController {
)
) {
overview = nullifyValuesInObject(overview, [
'cash',
'committedFunds',
'fees',
'totalBuy',
@ -238,10 +240,11 @@ export class PortfolioController {
@Query('range') range,
@Res() res: Response
): Promise<PortfolioPerformance> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
@ -306,10 +309,11 @@ export class PortfolioController {
public async getReport(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioReport> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id

View File

@ -1,3 +1,8 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
@ -11,10 +16,6 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { Module } from '@nestjs/common';
import { CacheService } from '../cache/cache.service';
import { OrderService } from '../order/order.service';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { UserService } from '../user/user.service';
import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service';
@ -22,6 +23,7 @@ import { PortfolioService } from './portfolio.service';
imports: [RedisCacheModule],
controllers: [PortfolioController],
providers: [
AccountService,
AlphaVantageService,
CacheService,
ConfigurationService,

View File

@ -1,3 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
@ -14,6 +18,8 @@ import { REQUEST } from '@nestjs/core';
import { DataSource } from '@prisma/client';
import {
add,
addMonths,
endOfToday,
format,
getDate,
getMonth,
@ -29,9 +35,6 @@ import {
import { isEmpty } from 'lodash';
import * as roundTo from 'round-to';
import { OrderService } from '../order/order.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { UserService } from '../user/user.service';
import {
HistoricalDataItem,
PortfolioPositionDetail
@ -40,6 +43,7 @@ import {
@Injectable()
export class PortfolioService {
public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService,
@ -52,7 +56,7 @@ export class PortfolioService {
public async createPortfolio(aUserId: string): Promise<Portfolio> {
let portfolio: Portfolio;
let stringifiedPortfolio = await this.redisCacheService.get(
const stringifiedPortfolio = await this.redisCacheService.get(
`${aUserId}.portfolio`
);
@ -63,11 +67,11 @@ export class PortfolioService {
const {
orders,
portfolioItems
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } = JSON.parse(
stringifiedPortfolio
);
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } =
JSON.parse(stringifiedPortfolio);
portfolio = new Portfolio(
this.accountService,
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
@ -84,6 +88,7 @@ export class PortfolioService {
});
portfolio = new Portfolio(
this.accountService,
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
@ -104,15 +109,21 @@ export class PortfolioService {
}
// Enrich portfolio with current data
return await portfolio.addCurrentPortfolioItems();
await portfolio.addCurrentPortfolioItems();
// Enrich portfolio with future data
await portfolio.addFuturePortfolioItems();
return portfolio;
}
public async findAll(aImpersonationId: string): Promise<PortfolioItem[]> {
try {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
@ -127,10 +138,11 @@ export class PortfolioService {
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
@ -148,6 +160,11 @@ export class PortfolioService {
return portfolio
.get()
.filter((portfolioItem) => {
if (isAfter(parseISO(portfolioItem.date), endOfToday())) {
// Filter out future dates
return false;
}
if (dateRangeDate === undefined) {
return true;
}
@ -170,21 +187,27 @@ export class PortfolioService {
public async getOverview(
aImpersonationId: string
): Promise<PortfolioOverview> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
const { balance } = await this.accountService.getCashDetails(
impersonationUserId || this.request.user.id,
this.request.user.Settings.currency
);
const committedFunds = portfolio.getCommittedFunds();
const fees = portfolio.getFees();
return {
committedFunds,
fees,
cash: balance,
ordersCount: portfolio.getOrders().length,
totalBuy: portfolio.getTotalBuy(),
totalSell: portfolio.getTotalSell()
@ -195,28 +218,28 @@ export class PortfolioService {
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
const positions = portfolio.getPositions(new Date())[aSymbol];
const position = portfolio.getPositions(new Date())[aSymbol];
if (positions) {
let {
if (position) {
const {
averagePrice,
currency,
firstBuyDate,
investment,
marketPrice,
quantity,
transactionCount
} = portfolio.getPositions(new Date())[aSymbol];
} = position;
let marketPrice = position.marketPrice;
const orders = portfolio.getOrders(aSymbol);
const historicalData = await this.dataProviderService.getHistorical(
@ -244,13 +267,14 @@ export class PortfolioService {
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
isAfter(currentDate, parseISO(orders[0]?.getDate()))
) {
// Get snapshot of first day of month
const snapshot = portfolio.get(setDate(currentDate, 1))[0]
.positions[aSymbol];
// Get snapshot of first day of next month
const snapshot = portfolio.get(
addMonths(setDate(currentDate, 1), 1)
)?.[0]?.positions[aSymbol];
orders.shift();
if (snapshot?.averagePrice) {
currentAveragePrice = snapshot?.averagePrice;
currentAveragePrice = snapshot.averagePrice;
}
}
@ -318,7 +342,7 @@ export class PortfolioService {
const historicalDataArray: HistoricalDataItem[] = [];
for (const [date, { marketPrice, performance }] of Object.entries(
for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol]
).reverse()) {
historicalDataArray.push({
@ -329,13 +353,13 @@ export class PortfolioService {
return {
averagePrice: undefined,
currency: currentData[aSymbol].currency,
currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: historicalDataArray,
investment: undefined,
marketPrice: currentData[aSymbol].marketPrice,
marketPrice: currentData[aSymbol]?.marketPrice,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,

View File

@ -0,0 +1,57 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
HttpException,
Inject,
Post,
Req,
Res,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { SubscriptionService } from './subscription.service';
@Controller('subscription')
export class SubscriptionController {
public constructor(
private readonly configurationService: ConfigurationService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly subscriptionService: SubscriptionService
) {}
@Get('stripe/callback')
public async stripeCallback(@Req() req, @Res() res) {
await this.subscriptionService.createSubscription(
req.query.checkoutSessionId
);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
}
@Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt'))
public async createCheckoutSession(
@Body() { couponId, priceId }: { couponId: string; priceId: string }
) {
try {
return await this.subscriptionService.createCheckoutSession({
couponId,
priceId,
userId: this.request.user.id
});
} catch (error) {
console.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

View File

@ -0,0 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { SubscriptionController } from './subscription.controller';
import { SubscriptionService } from './subscription.service';
@Module({
imports: [],
controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService]
})
export class SubscriptionModule {}

View File

@ -0,0 +1,89 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { addDays } from 'date-fns';
import Stripe from 'stripe';
@Injectable()
export class SubscriptionService {
private stripe: Stripe;
public constructor(
private readonly configurationService: ConfigurationService,
private prisma: PrismaService
) {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2020-08-27'
}
);
}
public async createCheckoutSession({
couponId,
priceId,
userId
}: {
couponId?: string;
priceId: string;
userId: string;
}) {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/account`,
client_reference_id: userId,
line_items: [
{
price: priceId,
quantity: 1
}
],
mode: 'payment',
payment_method_types: ['card'],
success_url: `${this.configurationService.get(
'ROOT_URL'
)}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
};
if (couponId) {
checkoutSessionCreateParams.discounts = [
{
coupon: couponId
}
];
}
const session = await this.stripe.checkout.sessions.create(
checkoutSessionCreateParams
);
return {
sessionId: session.id
};
}
public async createSubscription(aCheckoutSessionId: string) {
try {
const session = await this.stripe.checkout.sessions.retrieve(
aCheckoutSessionId
);
await this.prisma.subscription.create({
data: {
expiresAt: addDays(new Date(), 365),
User: {
connect: {
id: session.client_reference_id
}
}
}
});
await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id
});
} catch (error) {
console.error(error);
}
}
}

View File

@ -0,0 +1,7 @@
import { Currency, ViewMode } from '@prisma/client';
export interface UserSettingsParams {
currency?: Currency;
userId: string;
viewMode?: ViewMode;
}

View File

@ -25,6 +25,7 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { UserItem } from './interfaces/user-item.interface';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UpdateUserSettingsDto } from './update-user-settings.dto';
import { UserService } from './user.service';
@ -92,10 +93,20 @@ export class UserController {
);
}
return await this.userService.updateUserSettings({
const userSettings: UserSettingsParams = {
currency: data.baseCurrency,
userId: this.request.user.id,
viewMode: data.viewMode
});
userId: this.request.user.id
};
if (
hasPermission(
getPermissions(this.request.user.role),
permissions.updateViewMode
)
) {
userSettings.viewMode = data.viewMode;
}
return await this.userService.updateUserSettings(userSettings);
}
}

View File

@ -1,13 +1,14 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { locale } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
import { add, isBefore } from 'date-fns';
import { isBefore } from 'date-fns';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
const crypto = require('crypto');
@ -24,7 +25,7 @@ export class UserService {
Account,
alias,
id,
role,
permissions,
Settings,
subscription
}: UserWithSettings): Promise<IUser> {
@ -36,15 +37,10 @@ export class UserService {
where: { GranteeUser: { id } }
});
const currentPermissions = getPermissions(role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
return {
alias,
id,
permissions,
subscription,
access: access.map((accessItem) => {
return {
@ -53,7 +49,6 @@ export class UserService {
};
}),
accounts: Account,
permissions: currentPermissions,
settings: {
locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
@ -72,6 +67,14 @@ export class UserService {
const user: UserWithSettings = userFromDatabase;
const currentPermissions = getPermissions(userFromDatabase.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
user.permissions = currentPermissions;
if (userFromDatabase?.Settings) {
if (!userFromDatabase.Settings.currency) {
// Set default currency if needed
@ -106,6 +109,13 @@ export class UserService {
type: SubscriptionType.Basic
};
}
if (user.subscription.type === SubscriptionType.Basic) {
user.permissions = user.permissions.filter((permission) => {
return permission !== permissions.updateViewMode;
});
user.Settings.viewMode = ViewMode.ZEN;
}
}
return user;
@ -213,11 +223,7 @@ export class UserService {
currency,
userId,
viewMode
}: {
currency?: Currency;
userId: string;
viewMode?: ViewMode;
}) {
}: UserSettingsParams) {
await this.prisma.settings.upsert({
create: {
currency,

View File

@ -1,3 +1,4 @@
export const environment = {
production: true
production: true,
version: `v${require('../../../../package.json').version}`
};

View File

@ -1,3 +1,4 @@
export const environment = {
production: false
production: false,
version: 'dev'
};

View File

@ -1,4 +1,5 @@
import { Account, Currency, Platform, SymbolProfile } from '@prisma/client';
import { Account, Currency, SymbolProfile } from '@prisma/client';
import { endOfToday, isAfter, parseISO } from 'date-fns';
import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces';
@ -52,6 +53,10 @@ export class Order {
return this.id;
}
public getIsDraft() {
return isAfter(parseISO(this.date), endOfToday());
}
public getQuantity() {
return this.quantity;
}

View File

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { getUtc, getYesterday } from '@ghostfolio/common/helper';
import {
@ -16,6 +17,16 @@ import { MarketState } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { Portfolio } from './portfolio';
jest.mock('../app/account/account.service', () => {
return {
AccountService: jest.fn().mockImplementation(() => {
return {
getCashDetails: () => Promise.resolve({ accounts: [], balance: 0 })
};
})
};
});
jest.mock('../services/data-provider.service', () => {
return {
DataProviderService: jest.fn().mockImplementation(() => {
@ -81,12 +92,14 @@ const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
describe('Portfolio', () => {
let accountService: AccountService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolio: Portfolio;
let rulesService: RulesService;
beforeAll(async () => {
accountService = new AccountService(null, null, null);
dataProviderService = new DataProviderService(
null,
null,
@ -101,6 +114,7 @@ describe('Portfolio', () => {
await exchangeRateDataService.initialize();
portfolio = new Portfolio(
accountService,
dataProviderService,
exchangeRateDataService,
rulesService
@ -110,7 +124,9 @@ describe('Portfolio', () => {
Account: [
{
accountType: AccountType.SECURITIES,
balance: 0,
createdAt: new Date(),
currency: Currency.USD,
id: DEFAULT_ACCOUNT_ID,
isDefault: true,
name: 'Default Account',
@ -145,12 +161,52 @@ describe('Portfolio', () => {
it('should return empty details', async () => {
const details = await portfolio.getDetails('1d');
expect(details).toEqual({});
expect(details).toMatchObject({
_GF_CASH: {
accounts: {},
allocationCurrent: NaN, // TODO
allocationInvestment: NaN, // TODO
countries: [],
currency: 'CHF',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: 0,
marketPrice: 0,
marketState: 'open',
name: 'Cash',
quantity: 0,
sectors: [],
symbol: '_GF_CASH',
transactionCount: 0,
type: 'Cash',
value: 0
}
});
});
it('should return empty details', async () => {
const details = await portfolio.getDetails('max');
expect(details).toEqual({});
expect(details).toMatchObject({
_GF_CASH: {
accounts: {},
allocationCurrent: NaN, // TODO
allocationInvestment: NaN, // TODO
countries: [],
currency: 'CHF',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: 0,
marketPrice: 0,
marketState: 'open',
name: 'Cash',
quantity: 0,
sectors: [],
symbol: '_GF_CASH',
transactionCount: 0,
type: 'Cash',
value: 0
}
});
});
it('should return zero performance for 1d', async () => {
@ -275,7 +331,9 @@ describe('Portfolio', () => {
expect(portfolio.getPositions(getYesterday())).toMatchObject({});
expect(portfolio.getSymbols(getYesterday())).toEqual(['BTCUSD']);
expect(portfolio.getSymbols(getYesterday())).toEqual([]);
expect(portfolio.getSymbols(new Date())).toEqual(['BTCUSD']);
});
});
@ -309,16 +367,16 @@ describe('Portfolio', () => {
)
);
const details = await portfolio.getDetails('1d');
/*const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({
ETHUSD: {
accounts: {
[UNKNOWN_KEY]: {
/*current: exchangeRateDataService.toCurrency(
current: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),*/
),
original: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
@ -345,7 +403,7 @@ describe('Portfolio', () => {
symbol: 'ETHUSD',
type: 'Cryptocurrency'
}
});
});*/
expect(portfolio.getFees()).toEqual(0);

View File

@ -1,4 +1,6 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
import {
PortfolioItem,
@ -9,8 +11,9 @@ import {
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import { Prisma } from '@prisma/client';
import { Currency, Prisma } from '@prisma/client';
import { continents, countries } from 'countries-list';
import {
add,
@ -33,7 +36,7 @@ import * as roundTo from 'round-to';
import { DataProviderService } from '../services/data-provider.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { IOrder } from '../services/interfaces/interfaces';
import { IOrder, MarketState, Type } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { PortfolioInterface } from './interfaces/portfolio.interface';
import { Order } from './order';
@ -53,6 +56,7 @@ export class Portfolio implements PortfolioInterface {
private user: UserWithSettings;
public constructor(
private accountService: AccountService,
private dataProviderService: DataProviderService,
private exchangeRateDataService: ExchangeRateDataService,
private rulesService: RulesService
@ -72,7 +76,7 @@ export class Portfolio implements PortfolioInterface {
const [portfolioItemsYesterday] = this.get(yesterday);
let positions: { [symbol: string]: Position } = {};
const positions: { [symbol: string]: Position } = {};
this.getSymbols().forEach((symbol) => {
positions[symbol] = {
@ -104,14 +108,45 @@ export class Portfolio implements PortfolioInterface {
);
// Set value after pushing today's portfolio items
this.portfolioItems[portfolioItemsLength - 1].value = this.getValue(
today
);
this.portfolioItems[portfolioItemsLength - 1].value =
this.getValue(today);
}
return this;
}
public async addFuturePortfolioItems() {
let investment = this.getInvestment(new Date());
this.getOrders()
.filter((order) => order.getIsDraft() === true)
.forEach((order) => {
investment += this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
const portfolioItem = this.portfolioItems.find((item) => {
return item.date === order.getDate();
});
if (portfolioItem) {
portfolioItem.investment = investment;
} else {
this.portfolioItems.push({
investment,
date: order.getDate(),
grossPerformancePercent: 0,
positions: {},
value: 0
});
}
});
return this;
}
public createFromData({
orders,
portfolioItems,
@ -177,6 +212,8 @@ export class Portfolio implements PortfolioInterface {
if (filteredPortfolio) {
return [cloneDeep(filteredPortfolio)];
}
return [];
}
return cloneDeep(this.portfolioItems);
@ -198,10 +235,14 @@ export class Portfolio implements PortfolioInterface {
const [portfolioItemsNow] = await this.get(new Date());
const investment = this.getInvestment(new Date());
const cashDetails = await this.accountService.getCashDetails(
this.user.id,
this.user.Settings.currency
);
const investment = this.getInvestment(new Date()) + cashDetails.balance;
const portfolioItems = this.get(new Date());
const symbols = this.getSymbols(new Date());
const value = this.getValue();
const value = this.getValue() + cashDetails.balance;
const details: { [symbol: string]: PortfolioPosition } = {};
@ -210,6 +251,7 @@ export class Portfolio implements PortfolioInterface {
symbols.forEach((symbol) => {
const accounts: PortfolioPosition['accounts'] = {};
let countriesOfSymbol: Country[];
let sectorsOfSymbol: Sector[];
const [portfolioItem] = portfolioItems;
const ordersBySymbol = this.getOrders().filter((order) => {
@ -237,12 +279,10 @@ export class Portfolio implements PortfolioInterface {
if (
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current
) {
accounts[
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
].current += currentValueOfSymbol;
accounts[
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
].original += originalValueOfSymbol;
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].current +=
currentValueOfSymbol;
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].original +=
originalValueOfSymbol;
} else {
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = {
current: currentValueOfSymbol,
@ -264,12 +304,23 @@ export class Portfolio implements PortfolioInterface {
weight: weight as number
};
});
sectorsOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.sectors as Prisma.JsonArray) ?? []
).map((sector) => {
const { name, weight } = sector as Prisma.JsonObject;
return {
name: (name as string) ?? UNKNOWN_KEY,
weight: weight as number
};
});
});
let now = portfolioItemsNow.positions[symbol].marketPrice;
// 1d
let before = portfolioItemsBefore.positions[symbol].marketPrice;
let before = portfolioItemsBefore?.positions[symbol].marketPrice;
if (aDateRange === 'ytd') {
before =
@ -286,7 +337,7 @@ export class Portfolio implements PortfolioInterface {
if (
!isBefore(
parseISO(portfolioItemsNow.positions[symbol].firstBuyDate),
parseISO(portfolioItemsBefore.date)
parseISO(portfolioItemsBefore?.date)
)
) {
// Trade was not before the date of portfolioItemsBefore, then override it with average price
@ -318,6 +369,7 @@ export class Portfolio implements PortfolioInterface {
grossPerformancePercent: roundTo((now - before) / before, 4),
investment: portfolioItem.positions[symbol].investment,
quantity: portfolioItem.positions[symbol].quantity,
sectors: sectorsOfSymbol,
transactionCount: portfolioItem.positions[symbol].transactionCount,
value: this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
@ -327,6 +379,12 @@ export class Portfolio implements PortfolioInterface {
};
});
details[ghostfolioCashSymbol] = await this.getCashPosition({
cashDetails,
investment,
value
});
return details;
}
@ -351,7 +409,11 @@ export class Portfolio implements PortfolioInterface {
}
public getMinDate() {
if (this.orders.length > 0) {
const orders = this.getOrders().filter(
(order) => order.getIsDraft() === false
);
if (orders.length > 0) {
return new Date(this.orders[0].getDate());
}
@ -478,9 +540,11 @@ export class Portfolio implements PortfolioInterface {
}
}
} else {
symbols = this.orders.map((order) => {
return order.getSymbol();
});
symbols = this.orders
.filter((order) => order.getIsDraft() === false)
.map((order) => {
return order.getSymbol();
});
}
// unique values
@ -489,7 +553,9 @@ export class Portfolio implements PortfolioInterface {
public getTotalBuy() {
return this.orders
.filter((order) => order.getType() === 'BUY')
.filter(
(order) => order.getIsDraft() === false && order.getType() === 'BUY'
)
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.getTotal(),
@ -502,7 +568,9 @@ export class Portfolio implements PortfolioInterface {
public getTotalSell() {
return this.orders
.filter((order) => order.getType() === 'SELL')
.filter(
(order) => order.getIsDraft() === false && order.getType() === 'SELL'
)
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.getTotal(),
@ -589,6 +657,46 @@ export class Portfolio implements PortfolioInterface {
return this;
}
private async getCashPosition({
cashDetails,
investment,
value
}: {
cashDetails: CashDetails;
investment: number;
value: number;
}) {
const accounts = {};
const cashValue = cashDetails.balance;
cashDetails.accounts.forEach((account) => {
accounts[account.name] = {
current: account.balance,
original: account.balance
};
});
return {
accounts,
allocationCurrent: cashValue / value,
allocationInvestment: cashValue / investment,
countries: [],
currency: Currency.CHF,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: cashValue,
marketPrice: 0,
marketState: MarketState.open,
name: Type.Cash,
quantity: 0,
sectors: [],
symbol: ghostfolioCashSymbol,
type: Type.Cash,
transactionCount: 0,
value: cashValue
};
}
/**
* TODO: Refactor
*/
@ -672,10 +780,10 @@ export class Portfolio implements PortfolioInterface {
this.portfolioItems.push(
cloneDeep({
positions,
date: yesterday.toISOString(),
grossPerformancePercent: 0,
investment: 0,
positions: positions,
value: 0
})
);
@ -732,8 +840,6 @@ export class Portfolio implements PortfolioInterface {
}
private updatePortfolioItems() {
// console.time('update-portfolio-items');
let currentDate = new Date();
const year = getYear(currentDate);
@ -757,107 +863,99 @@ export class Portfolio implements PortfolioInterface {
}
this.orders.forEach((order) => {
let index = this.portfolioItems.findIndex((item) => {
const dateOfOrder = setDate(parseISO(order.getDate()), 1);
return isSameDay(parseISO(item.date), dateOfOrder);
});
if (order.getIsDraft() === false) {
let index = this.portfolioItems.findIndex((item) => {
const dateOfOrder = setDate(parseISO(order.getDate()), 1);
return isSameDay(parseISO(item.date), dateOfOrder);
});
if (index === -1) {
// if not found, we only have one order, which means we do not loop below
index = 0;
}
for (let i = index; i < this.portfolioItems.length; i++) {
// Set currency
this.portfolioItems[i].positions[
order.getSymbol()
].currency = order.getCurrency();
this.portfolioItems[i].positions[
order.getSymbol()
].transactionCount += 1;
if (order.getType() === 'BUY') {
if (
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate
) {
this.portfolioItems[i].positions[
order.getSymbol()
].firstBuyDate = resetHours(
parseISO(order.getDate())
).toISOString();
}
this.portfolioItems[i].positions[
order.getSymbol()
].quantity += order.getQuantity();
this.portfolioItems[i].positions[
order.getSymbol()
].investment += this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency += order.getTotal();
this.portfolioItems[
i
].investment += this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
} else if (order.getType() === 'SELL') {
this.portfolioItems[i].positions[
order.getSymbol()
].quantity -= order.getQuantity();
if (
this.portfolioItems[i].positions[order.getSymbol()].quantity === 0
) {
this.portfolioItems[i].positions[order.getSymbol()].investment = 0;
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency = 0;
} else {
this.portfolioItems[i].positions[
order.getSymbol()
].investment -= this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency -= order.getTotal();
}
this.portfolioItems[
i
].investment -= this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
if (index === -1) {
// if not found, we only have one order, which means we do not loop below
index = 0;
}
this.portfolioItems[i].positions[order.getSymbol()].averagePrice =
this.portfolioItems[i].positions[order.getSymbol()]
.investmentInOriginalCurrency /
this.portfolioItems[i].positions[order.getSymbol()].quantity;
for (let i = index; i < this.portfolioItems.length; i++) {
// Set currency
this.portfolioItems[i].positions[order.getSymbol()].currency =
order.getCurrency();
const currentValue = this.getValue(
parseISO(this.portfolioItems[i].date)
);
this.portfolioItems[i].positions[
order.getSymbol()
].transactionCount += 1;
this.portfolioItems[i].grossPerformancePercent =
currentValue / this.portfolioItems[i].investment - 1 || 0;
this.portfolioItems[i].value = currentValue;
if (order.getType() === 'BUY') {
if (
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate
) {
this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate =
resetHours(parseISO(order.getDate())).toISOString();
}
this.portfolioItems[i].positions[order.getSymbol()].quantity +=
order.getQuantity();
this.portfolioItems[i].positions[order.getSymbol()].investment +=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency += order.getTotal();
this.portfolioItems[i].investment +=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
} else if (order.getType() === 'SELL') {
this.portfolioItems[i].positions[order.getSymbol()].quantity -=
order.getQuantity();
if (
this.portfolioItems[i].positions[order.getSymbol()].quantity === 0
) {
this.portfolioItems[i].positions[
order.getSymbol()
].investment = 0;
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency = 0;
} else {
this.portfolioItems[i].positions[order.getSymbol()].investment -=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency -= order.getTotal();
}
this.portfolioItems[i].investment -=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
}
this.portfolioItems[i].positions[order.getSymbol()].averagePrice =
this.portfolioItems[i].positions[order.getSymbol()]
.investmentInOriginalCurrency /
this.portfolioItems[i].positions[order.getSymbol()].quantity;
const currentValue = this.getValue(
parseISO(this.portfolioItems[i].date)
);
this.portfolioItems[i].grossPerformancePercent =
currentValue / this.portfolioItems[i].investment - 1 || 0;
this.portfolioItems[i].value = currentValue;
}
}
});
// console.timeEnd('update-portfolio-items');
}
}

View File

@ -17,6 +17,7 @@ export class ConfigurationService {
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }),
@ -27,6 +28,7 @@ export class ConfigurationService {
REDIS_HOST: str({ default: 'localhost' }),
REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }),
STRIPE_SECRET_KEY: str({ default: '' }),
WEB_AUTH_RP_ID: host({ default: 'localhost' })
});
}

View File

@ -8,6 +8,7 @@ import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import {
differenceInHours,
endOfToday,
format,
getDate,
getMonth,
@ -187,7 +188,8 @@ export class DataGatheringService {
public async getCustomSymbolsToGather(
startDate?: Date
): Promise<IDataGatheringItem[]> {
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations();
const scraperConfigurations =
await this.ghostfolioScraperApi.getScraperConfigurations();
return scraperConfigurations.map((scraperConfiguration) => {
return {
@ -224,7 +226,12 @@ export class DataGatheringService {
const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true }
select: { dataSource: true, symbol: true },
where: {
date: {
lt: endOfToday() // no draft
}
}
});
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
@ -280,7 +287,12 @@ export class DataGatheringService {
const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'],
orderBy: [{ date: 'asc' }],
select: { dataSource: true, date: true, symbol: true }
select: { dataSource: true, date: true, symbol: true },
where: {
date: {
lt: endOfToday() // no draft
}
}
});
return [

View File

@ -12,9 +12,7 @@ import { DataProviderInterface } from '../../interfaces/data-provider.interface'
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
Industry,
MarketState,
Sector,
Type
} from '../../interfaces/interfaces';
import {
@ -70,16 +68,6 @@ export class YahooFinanceService implements DataProviderInterface {
type: this.parseType(this.getType(symbol, value))
};
const industry = this.parseIndustry(value.summaryProfile?.industry);
if (industry) {
response[symbol].industry = industry;
}
const sector = this.parseSector(value.summaryProfile?.sector);
if (sector) {
response[symbol].sector = sector;
}
const url = value.summaryProfile?.website;
if (url) {
response[symbol].url = url;
@ -228,55 +216,6 @@ export class YahooFinanceService implements DataProviderInterface {
return aString;
}
private parseIndustry(aString: string): Industry {
if (aString === undefined) {
return undefined;
}
if (aString?.toLowerCase() === 'auto manufacturers') {
return Industry.Automotive;
} else if (aString?.toLowerCase() === 'biotechnology') {
return Industry.Biotechnology;
} else if (
aString?.toLowerCase() === 'drug manufacturers—specialty & generic'
) {
return Industry.Pharmaceutical;
} else if (
aString?.toLowerCase() === 'internet content & information' ||
aString?.toLowerCase() === 'internet retail'
) {
return Industry.Internet;
} else if (aString?.toLowerCase() === 'packaged foods') {
return Industry.Food;
} else if (aString?.toLowerCase() === 'software—application') {
return Industry.Software;
}
return Industry.Unknown;
}
private parseSector(aString: string): Sector {
if (aString === undefined) {
return undefined;
}
if (
aString?.toLowerCase() === 'consumer cyclical' ||
aString?.toLowerCase() === 'consumer defensive'
) {
return Sector.Consumer;
} else if (aString?.toLowerCase() === 'healthcare') {
return Sector.Healthcare;
} else if (
aString?.toLowerCase() === 'communication services' ||
aString?.toLowerCase() === 'technology'
) {
return Sector.Technology;
}
return Sector.Unknown;
}
private parseType(aString: string): Type {
if (aString?.toLowerCase() === 'cryptocurrency') {
return Type.Cryptocurrency;
@ -291,6 +230,6 @@ export class YahooFinanceService implements DataProviderInterface {
}
export const convertFromYahooSymbol = (aSymbol: string) => {
let symbol = aSymbol.replace('-', '');
const symbol = aSymbol.replace('-', '');
return symbol.replace('=X', '');
};

View File

@ -8,6 +8,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean;
GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string;
@ -18,5 +19,6 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_HOST: string;
REDIS_PORT: number;
ROOT_URL: string;
STRIPE_SECRET_KEY: string;
WEB_AUTH_RP_ID: string;
}

View File

@ -3,30 +3,14 @@ import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
import { OrderType } from '../../models/order-type';
export const Industry = {
Automotive: 'Automotive',
Biotechnology: 'Biotechnology',
Food: 'Food',
Internet: 'Internet',
Pharmaceutical: 'Pharmaceutical',
Software: 'Software',
Unknown: UNKNOWN_KEY
};
export const MarketState = {
closed: 'closed',
delayed: 'delayed',
open: 'open'
};
export const Sector = {
Consumer: 'Consumer',
Healthcare: 'Healthcare',
Technology: 'Technology',
Unknown: UNKNOWN_KEY
};
export const Type = {
Cash: 'Cash',
Cryptocurrency: 'Cryptocurrency',
ETF: 'ETF',
Stock: 'Stock',
@ -55,13 +39,11 @@ export interface IDataProviderResponse {
currency: Currency;
dataSource: DataSource;
exchange?: string;
industry?: Industry;
marketChange?: number;
marketChangePercent?: number;
marketPrice: number;
marketState: MarketState;
name: string;
sector?: Sector;
type?: Type;
url?: string;
}
@ -72,10 +54,6 @@ export interface IDataGatheringItem {
symbol: string;
}
export type Industry = typeof Industry[keyof typeof Industry];
export type MarketState = typeof MarketState[keyof typeof MarketState];
export type Sector = typeof Sector[keyof typeof Sector];
export type Type = typeof Type[keyof typeof Type];

View File

@ -5,19 +5,14 @@ module.exports = {
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
astTransformers: {
before: [
'jest-preset-angular/build/InlineFilesTransformer',
'jest-preset-angular/build/StripStylesTransformer'
]
}
stringifyContentPathRegex: '\\.(html|svg)$'
}
},
coverageDirectory: '../../coverage/apps/client',
snapshotSerializers: [
'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js',
'jest-preset-angular/build/AngularSnapshotSerializer.js',
'jest-preset-angular/build/HTMLCommentSerializer.js'
]
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment'
],
transform: { '^.+\\.(ts|js|html)$': 'jest-preset-angular' }
};

View File

@ -92,6 +92,13 @@ const routes: Routes = [
(m) => m.TransactionsPageModule
)
},
{
path: 'webauthn',
loadChildren: () =>
import('./pages/webauthn/webauthn-page.module').then(
(m) => m.WebauthnPageModule
)
},
{
path: 'zen',
loadChildren: () =>

View File

@ -7,10 +7,6 @@
.create-account-box {
cursor: pointer;
font-size: 90%;
.link {
color: rgba(var(--palette-primary-500), 1);
}
}
}

View File

@ -52,9 +52,12 @@ export class AppComponent implements OnDestroy, OnInit {
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService.fetchInfo().subscribe((info) => {
this.info = info;
});
this.dataService
.fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((info) => {
this.info = info;
});
this.router.events
.pipe(filter((event) => event instanceof NavigationEnd))

View File

@ -15,7 +15,9 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MaterialCssVarsModule } from 'angular-material-css-vars';
import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { NgxStripeModule } from 'ngx-stripe';
import { environment } from '../environments/environment';
import { CustomDateAdapter } from './adapter/custom-date-adapter';
import { DateFormats } from './adapter/date-formats';
import { AppRoutingModule } from './app-routing.module';
@ -43,7 +45,8 @@ import { LanguageService } from './core/language.service';
}),
MatNativeDateModule,
MatSnackBarModule,
NgxSkeletonLoaderModule
NgxSkeletonLoaderModule,
NgxStripeModule.forRoot(environment.stripePublicKey)
],
providers: [
authInterceptorProviders,

View File

@ -26,6 +26,27 @@
</td>
</ng-container>
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Transactions
</th>
<td *matCellDef="let element" class="text-right" mat-cell>
{{ element.Order?.length }}
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>Balance</th>
<td *matCellDef="let element" class="text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[currency]="element.currency"
[locale]="locale"
[value]="element.balance"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
@ -53,15 +74,6 @@
</td>
</ng-container>
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Transactions
</th>
<td *matCellDef="let element" class="text-right" mat-cell>
{{ element.Order?.length }}
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>

View File

@ -28,7 +28,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>();
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
public dataSource: MatTableDataSource<AccountModel> =
new MatTableDataSource();
public displayedColumns = [];
public isLoading = true;
public routeQueryParams: Subscription;
@ -40,7 +41,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = ['account', 'platform', 'transactions'];
this.displayedColumns = ['account', 'platform', 'transactions', 'balance'];
if (this.showActions) {
this.displayedColumns.push('actions');

View File

@ -5,10 +5,7 @@
z-index: 999;
.mat-toolbar {
background-color: rgba(
var(--light-primary-text),
var(--palette-foreground-disabled-alpha)
);
background-color: rgba(var(--light-disabled-text));
.spacer {
flex: 1 1 auto;
@ -28,11 +25,6 @@
:host-context(.is-dark-theme) {
.mat-toolbar {
background-color: rgba(
39,
39,
39,
var(--palette-foreground-disabled-alpha)
);
background-color: rgba(39, 39, 39, $alpha-disabled-text);
}
}

View File

@ -11,8 +11,11 @@ import { Router } from '@angular/router';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { EMPTY, Subject } from 'rxjs';
@ -43,11 +46,12 @@ export class HeaderComponent implements OnChanges {
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private router: Router,
private tokenStorageService: TokenStorageService,
private webAuthnService: WebAuthnService
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService
) {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((id) => {
this.impersonationId = id;
});
@ -95,28 +99,39 @@ export class HeaderComponent implements OnChanges {
width: '30rem'
});
dialogRef.afterClosed().subscribe((data) => {
if (data?.accessToken) {
this.dataService
.loginAnonymous(data?.accessToken)
.pipe(
catchError(() => {
alert('Oops! Incorrect Security Token.');
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
if (data?.accessToken) {
this.dataService
.loginAnonymous(data?.accessToken)
.pipe(
catchError(() => {
alert('Oops! Incorrect Security Token.');
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ authToken }) => {
this.setToken(authToken, data.staySignedIn);
});
}
});
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ authToken }) => {
this.setToken(authToken);
});
}
});
}
public setToken(aToken: string, staySignedIn: boolean) {
this.tokenStorageService.saveToken(aToken, staySignedIn);
public setToken(aToken: string) {
this.tokenStorageService.saveToken(
aToken,
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
);
this.router.navigate(['/']);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -2,12 +2,12 @@
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '12rem',
height: '100%',
width: '100%'
}"
></ngx-skeleton-loader>
<canvas
#chartCanvas
height="50"
class="h-100"
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas>

View File

@ -19,6 +19,7 @@ import {
TimeScale
} from 'chart.js';
import { Chart } from 'chart.js';
import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
@Component({
selector: 'gf-investment-chart',
@ -52,9 +53,30 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
}
}
public ngOnDestroy() {
this.chart?.destroy();
}
private initialize() {
this.isLoading = true;
if (this.portfolioItems?.length > 0) {
// Extend chart by three months (before)
const firstItem = this.portfolioItems[0];
this.portfolioItems.unshift({
...firstItem,
date: subMonths(parseISO(firstItem.date), 3).toISOString(),
investment: 0
});
// Extend chart by three months (after)
const lastItem = this.portfolioItems[this.portfolioItems.length - 1];
this.portfolioItems.push({
...lastItem,
date: addMonths(parseISO(lastItem.date), 3).toISOString()
});
}
const data = {
labels: this.portfolioItems.map((position) => {
return position.date;
@ -65,7 +87,16 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
borderWidth: 2,
data: this.portfolioItems.map((position) => {
return position.investment;
})
}),
segment: {
borderColor: (context: unknown) =>
this.isInFuture(
context,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.67)`
),
borderDash: (context: unknown) => this.isInFuture(context, [2, 2])
},
stepped: true
}
]
};
@ -123,7 +154,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
}
}
public ngOnDestroy() {
this.chart?.destroy();
private isInFuture(aContext: any, aValue: any) {
return isAfter(new Date(aContext?.p0?.parsed?.x), new Date())
? aValue
: undefined;
}
}

View File

@ -1,5 +1,10 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
@Component({
selector: 'gf-login-with-access-token-dialog',
@ -9,13 +14,21 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
})
export class LoginWithAccessTokenDialog {
public constructor(
@Inject(MAT_DIALOG_DATA) public data: any,
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
@Inject(MAT_DIALOG_DATA) public data: any
private settingsStorageService: SettingsStorageService
) {}
ngOnInit() {}
public onClose(): void {
public onChangeStaySignedIn(aValue: MatCheckboxChange) {
this.settingsStorageService.setSetting(
STAY_SIGNED_IN,
aValue.checked?.toString()
);
}
public onClose() {
this.dialogRef.close();
}
}

View File

@ -28,7 +28,7 @@
</div>
<div mat-dialog-actions>
<div class="flex-grow-1">
<mat-checkbox i18n [(ngModel)]="data.staySignedIn"
<mat-checkbox i18n (change)="onChangeStaySignedIn($event)"
>Stay signed in</mat-checkbox
>
</div>

View File

@ -7,6 +7,8 @@ import {
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { isToday, parse } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { LineChartItem } from '../line-chart/interfaces/line-chart.interface';
import { PositionDetailDialogParams } from './interfaces/interfaces';
@ -27,6 +29,8 @@ export class PerformanceChartDialog {
public historicalDataItems: LineChartItem[];
public title: string;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -35,6 +39,7 @@ export class PerformanceChartDialog {
) {
this.dataService
.fetchPositionDetail(this.benchmarkSymbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currency, firstBuyDate, historicalData, marketPrice }) => {
this.benchmarkDataItems = [];
this.currency = currency;
@ -84,4 +89,9 @@ export class PerformanceChartDialog {
public onClose(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -1,4 +1,18 @@
<div class="container p-0">
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Cash</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.cash"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Buy</div>
<div class="d-flex justify-content-end">

View File

@ -8,7 +8,7 @@ import {
ViewChild
} from '@angular/core';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getCssVariable, getTextColor } from '@ghostfolio/common/helper';
import { getTextColor } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { Tooltip } from 'chart.js';
@ -24,7 +24,8 @@ import { Chart } from 'chart.js';
styleUrls: ['./portfolio-proportion-chart.component.scss']
})
export class PortfolioProportionChartComponent
implements OnChanges, OnDestroy, OnInit {
implements OnChanges, OnDestroy, OnInit
{
@Input() baseCurrency: Currency;
@Input() isInPercent: boolean;
@Input() key: string;
@ -42,9 +43,7 @@ export class PortfolioProportionChartComponent
private colorMap: {
[symbol: string]: string;
} = {
[UNKNOWN_KEY]: `rgba(${getTextColor()}, ${getCssVariable(
'--palette-foreground-divider-alpha'
)})`
[UNKNOWN_KEY]: `rgba(${getTextColor()}, 0.12)`
};
public constructor() {
@ -72,9 +71,8 @@ export class PortfolioProportionChartComponent
Object.keys(this.positions).forEach((symbol) => {
if (this.positions[symbol][this.key]) {
if (chartData[this.positions[symbol][this.key]]) {
chartData[this.positions[symbol][this.key]].value += this.positions[
symbol
].value;
chartData[this.positions[symbol][this.key]].value +=
this.positions[symbol].value;
} else {
chartData[this.positions[symbol][this.key]] = {
value: this.positions[symbol].value
@ -114,7 +112,11 @@ export class PortfolioProportionChartComponent
}
rest.forEach((restItem) => {
unknownItem[1] = { value: unknownItem[1].value + restItem[1].value };
if (unknownItem?.[1]) {
unknownItem[1] = {
value: unknownItem[1].value + restItem[1].value
};
}
});
// Sort data again

View File

@ -2,11 +2,14 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject
Inject,
OnDestroy
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { LineChartItem } from '../../line-chart/interfaces/line-chart.interface';
import { PositionDetailDialogParams } from './interfaces/interfaces';
@ -18,7 +21,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'position-detail-dialog.html',
styleUrls: ['./position-detail-dialog.component.scss']
})
export class PositionDetailDialog {
export class PositionDetailDialog implements OnDestroy {
public averagePrice: number;
public benchmarkDataItems: LineChartItem[];
public currency: string;
@ -33,6 +36,8 @@ export class PositionDetailDialog {
public quantity: number;
public transactionCount: number;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -41,6 +46,7 @@ export class PositionDetailDialog {
) {
this.dataService
.fetchPositionDetail(data.symbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
averagePrice,
@ -135,4 +141,9 @@ export class PositionDetailDialog {
public onClose(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -72,8 +72,11 @@ export class PositionComponent implements OnDestroy, OnInit {
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
}

View File

@ -82,7 +82,13 @@
<tr
*matRowDef="let row; columns: displayedColumns"
mat-row
(click)="onOpenPositionDialog({ symbol: row.symbol, title: row.name })"
[ngClass]="{
'cursor-pointer': !this.ignoreTypes.includes(row.type)
}"
(click)="
!this.ignoreTypes.includes(row.type) &&
onOpenPositionDialog({ symbol: row.symbol, title: row.name })
"
></tr>
</table>

View File

@ -19,7 +19,9 @@
}
.mat-row {
cursor: pointer;
&.cursor-pointer {
cursor: pointer;
}
}
}
}

View File

@ -4,6 +4,7 @@ import {
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
ViewChild
@ -13,6 +14,7 @@ import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { Type } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
@ -26,7 +28,7 @@ import { PositionDetailDialog } from '../position/position-detail-dialog/positio
templateUrl: './positions-table.component.html',
styleUrls: ['./positions-table.component.scss']
})
export class PositionsTableComponent implements OnChanges, OnInit {
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() locale: string;
@ -38,8 +40,10 @@ export class PositionsTableComponent implements OnChanges, OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<PortfolioPosition> = new MatTableDataSource();
public dataSource: MatTableDataSource<PortfolioPosition> =
new MatTableDataSource();
public displayedColumns = [];
public ignoreTypes = [Type.Cash];
public isLoading = true;
public pageSize = 7;
public routeQueryParams: Subscription;
@ -133,9 +137,12 @@ export class PositionsTableComponent implements OnChanges, OnInit {
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() {

View File

@ -1,12 +1,11 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
gf-position {
&:nth-child(even) {
background-color: rgba(
var(--dark-primary-text),
var(--palette-background-hover-alpha)
);
background-color: rgba(0, 0, 0, $alpha-hover);
}
}
}
@ -14,10 +13,7 @@
:host-context(.is-dark-theme) {
gf-position {
&:nth-child(even) {
background-color: rgba(
var(--light-primary-text),
var(--palette-background-hover-alpha)
);
background-color: rgba(255, 255, 255, $alpha-hover);
}
}
}

View File

@ -5,7 +5,10 @@ import {
OnChanges,
OnInit
} from '@angular/core';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import {
MarketState,
Type
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioPosition } from '@ghostfolio/common/interfaces/portfolio-position.interface';
@Component({
@ -25,6 +28,8 @@ export class PositionsComponent implements OnChanges, OnInit {
public positionsRest: PortfolioPosition[] = [];
public positionsWithPriority: PortfolioPosition[] = [];
private ignoreTypes = [Type.Cash];
public constructor() {}
public ngOnInit() {}
@ -41,6 +46,10 @@ export class PositionsComponent implements OnChanges, OnInit {
this.positionsWithPriority = [];
for (const [, portfolioPosition] of Object.entries(this.positions)) {
if (this.ignoreTypes.includes(portfolioPosition.type)) {
continue;
}
if (
portfolioPosition.marketState === MarketState.open ||
this.range !== '1d'

View File

@ -7,10 +7,7 @@
padding: 0.15rem 0.75rem;
&.mat-radio-checked {
background-color: rgba(
var(--dark-primary-text),
var(--palette-foreground-divider-alpha)
);
background-color: rgba(var(--dark-dividers));
}
::ng-deep {
@ -33,15 +30,8 @@
:host-context(.is-dark-theme) {
.mat-radio-button {
&.mat-radio-checked {
background-color: rgba(
var(--light-primary-text),
var(--palette-foreground-divider-alpha)
);
border: 1px solid
rgba(
var(--light-primary-text),
var(--palette-foreground-disabled-button-alpha)
);
background-color: rgba(var(--light-dividers));
border: 1px solid rgba(var(--light-disabled-text));
}
::ng-deep {

View File

@ -41,56 +41,50 @@
[dataSource]="dataSource"
>
<ng-container matColumnDef="count">
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>#</th>
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
>
#
</th>
<td
*matCellDef="let element; let i = index"
class="px-1 text-right"
class="d-none d-lg-table-cell px-1 text-right"
mat-cell
>
{{ dataSource.data.length - i }}
</td>
</ng-container>
<ng-container matColumnDef="date">
<th
*matHeaderCellDef
class="justify-content-center px-1"
i18n
mat-header-cell
mat-sort-header
>
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Date
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-center">
<div class="d-flex">
{{ element.date | date: defaultDateFormat }}
</div>
</td>
</ng-container>
<ng-container matColumnDef="type">
<th
*matHeaderCellDef
class="justify-content-center px-1"
i18n
mat-header-cell
mat-sort-header
>
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Type
</th>
<td mat-cell *matCellDef="let element" class="px-1 text-center">
<td mat-cell *matCellDef="let element" class="px-1">
<div
class="d-inline-flex justify-content-center pl-1 pr-2 py-1 type-badge"
class="d-inline-flex p-1 type-badge"
[ngClass]="element.type == 'BUY' ? 'buy' : 'sell'"
>
<ion-icon
class="mr-1"
[name]="
element.type === 'BUY'
? 'arrow-forward-circle-outline'
: 'arrow-back-circle-outline'
"
></ion-icon>
<span>{{ element.type }}</span>
<span class="d-none d-lg-block mx-1">{{ element.type }}</span>
</div>
</td>
</ng-container>
@ -100,24 +94,30 @@
Symbol
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.symbol | gfSymbol }}
<div class="d-flex align-items-center">
{{ element.symbol | gfSymbol }}
<span
*ngIf="isAfter(element.date, endOfToday)"
class="badge badge-secondary ml-1"
i18n
>Draft</span
>
</div>
</td>
</ng-container>
<ng-container matColumnDef="currency">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-center px-1"
mat-header-cell
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
mat-sort-header
>
Currency
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-center">
{{ element.currency }}
</div>
{{ element.currency }}
</td>
</ng-container>
@ -185,7 +185,9 @@
</ng-container>
<ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Account</th>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<span class="d-none d-lg-block" i18n>Account</span>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
<gf-symbol-icon
@ -200,17 +202,29 @@
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
[matMenuTriggerFor]="transactionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<mat-menu #transactionsMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onExport()">Export</button>
</mat-menu>
</th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="transactionMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #transactionMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onUpdateTransaction(element)">
Edit
</button>

View File

@ -23,7 +23,7 @@ import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { format } from 'date-fns';
import { endOfToday, format, isAfter } from 'date-fns';
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -39,13 +39,15 @@ const SEARCH_STRING_SEPARATOR = ',';
styleUrls: ['./transactions-table.component.scss']
})
export class TransactionsTableComponent
implements OnChanges, OnDestroy, OnInit {
implements OnChanges, OnDestroy, OnInit
{
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() locale: string;
@Input() showActions: boolean;
@Input() transactions: OrderWithAccount[];
@Output() export = new EventEmitter<void>();
@Output() transactionDeleted = new EventEmitter<string>();
@Output() transactionToClone = new EventEmitter<OrderWithAccount>();
@Output() transactionToUpdate = new EventEmitter<OrderWithAccount>();
@ -54,11 +56,14 @@ export class TransactionsTableComponent
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<OrderWithAccount> = new MatTableDataSource();
public dataSource: MatTableDataSource<OrderWithAccount> =
new MatTableDataSource();
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public displayedColumns = [];
public endOfToday = endOfToday();
public filters$: Subject<string[]> = new BehaviorSubject([]);
public filters: Observable<string[]> = this.filters$.asObservable();
public isAfter = isAfter;
public isLoading = true;
public placeholder = '';
public routeQueryParams: Subscription;
@ -85,18 +90,20 @@ export class TransactionsTableComponent
}
});
this.searchControl.valueChanges.subscribe((keyword) => {
if (keyword) {
const filterValue = keyword.toLowerCase();
this.filters$.next(
this.allFilters.filter(
(filter) => filter.toLowerCase().indexOf(filterValue) === 0
)
);
} else {
this.filters$.next(this.allFilters);
}
});
this.searchControl.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((keyword) => {
if (keyword) {
const filterValue = keyword.toLowerCase();
this.filters$.next(
this.allFilters.filter(
(filter) => filter.toLowerCase().indexOf(filterValue) === 0
)
);
} else {
this.filters$.next(this.allFilters);
}
});
}
public addKeyword({ input, value }: MatChipInputEvent): void {
@ -179,6 +186,10 @@ export class TransactionsTableComponent
}
}
public onExport() {
this.export.emit();
}
public onOpenPositionDialog({
symbol,
title
@ -219,9 +230,12 @@ export class TransactionsTableComponent
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() {

View File

@ -40,7 +40,10 @@ export class AuthGuard implements CanActivate {
.get()
.pipe(
catchError(() => {
if (AuthGuard.PUBLIC_PAGE_ROUTES.includes(state.url)) {
if (route.queryParams?.utm_source) {
this.router.navigate(['/register']);
resolve(false);
} else if (AuthGuard.PUBLIC_PAGE_ROUTES.includes(state.url)) {
resolve(true);
return EMPTY;
} else if (state.url !== '/start') {

View File

@ -79,10 +79,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
}
} else if (error.status === StatusCodes.UNAUTHORIZED) {
if (this.webAuthnService.isEnabled()) {
this.webAuthnService.login().subscribe(({ authToken }) => {
this.tokenStorageService.saveToken(authToken, false);
window.location.reload();
});
this.router.navigate(['/webauthn']);
} else {
this.tokenStorageService.signOut();
}

View File

@ -1,7 +1,10 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -12,10 +15,12 @@ import { environment } from '../../../environments/environment';
templateUrl: './about-page.html',
styleUrls: ['./about-page.scss']
})
export class AboutPageComponent implements OnInit {
export class AboutPageComponent implements OnDestroy, OnInit {
public baseCurrency = baseCurrency;
public hasPermissionForStatistics: boolean;
public isLoggedIn: boolean;
public lastPublish = environment.lastPublish;
public statistics: Statistics;
public user: User;
public version = environment.version;
@ -26,6 +31,7 @@ export class AboutPageComponent implements OnInit {
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private userService: UserService
) {}
@ -33,6 +39,20 @@ export class AboutPageComponent implements OnInit {
* Initializes the controller
*/
public ngOnInit() {
this.dataService
.fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ globalPermissions, statistics }) => {
this.hasPermissionForStatistics = hasPermission(
globalPermissions,
permissions.enableStatistics
);
this.statistics = statistics;
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {

View File

@ -2,14 +2,21 @@
<div class="mb-5 row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
<mat-card class="mb-3">
<mat-card>
<mat-card-content>
<p>
<strong>Ghostfolio</strong> is open source software which empowers
busy folks to have a sharp look of their financial assets and to
make solid, data-driven investment decisions by evaluating automated
static portfolio analysis rules. The project has been initiated by
<a href="https://dotsilver.ch">Thomas Kaul</a>.
<strong>Ghostfolio</strong> is a lightweight wealth management
application for individuals to keep track of their wealth like
stocks, ETFs or cryptocurrencies and make solid, data-driven
investment decisions. The source code is fully available as open
source software (OSS). The project has been initiated by
<a href="https://dotsilver.ch">Thomas Kaul</a> and is driven by the
efforts of its
<a
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
title="Contributors to Ghostfolio"
>contributors</a
>.
<ng-container *ngIf="lastPublish">
This instance is running Ghostfolio {{ version }} and has been
last published on {{ lastPublish }}.</ng-container
@ -17,21 +24,13 @@
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new feature, please open an issue at
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>, tweet
to <a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or
send an e-mail to
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
new feature, please tweet to
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>, send an
e-mail to <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or open
an issue at
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
@ -48,6 +47,14 @@
>
<ion-icon name="mail" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
</p>
<div class="d-flex justify-content-center">
<div
@ -60,10 +67,44 @@
</div>
</div>
<div *ngIf="hasPermissionForStatistics" class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Ghostfolio in Numbers</h3>
<mat-card>
<mat-card-content>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0" [hidden]="!statistics?.activeUsers1d">
{{ statistics?.activeUsers1d ?? '-' }}
</h3>
<div class="h6 mb-0">
Active Users <small class="text-muted">(Last 24 hours)</small>
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0" [hidden]="!statistics?.activeUsers30d">
{{ statistics?.activeUsers30d ?? '-' }}
</h3>
<div class="h6 mb-0">
Active Users <small class="text-muted">(Last 30 days)</small>
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0" [hidden]="!statistics?.gitHubStargazers">
{{ statistics?.gitHubStargazers ?? '-' }}
</h3>
<div class="h6 mb-0">Stars on GitHub</div>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Changelog</h3>
<mat-card class="changelog mb-3">
<mat-card class="changelog">
<mat-card-content>
<markdown [src]="'CHANGELOG.md'"></markdown>
</mat-card-content>
@ -74,7 +115,7 @@
<div class="row">
<div class="col">
<h3 class="mb-3 text-center" i18n>License</h3>
<mat-card class="mb-3">
<mat-card>
<mat-card-content>
<markdown [src]="'LICENSE'"></markdown>
</mat-card-content>

View File

@ -12,12 +12,13 @@ import {
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Currency } from '@prisma/client';
import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-account-page',
@ -29,10 +30,16 @@ export class AccountPageComponent implements OnDestroy, OnInit {
signInWithFingerprintElement: MatSlideToggle;
public accesses: Access[];
public baseCurrency: Currency;
public baseCurrency = baseCurrency;
public coupon: number;
public couponId: string;
public currencies: Currency[] = [];
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public hasPermissionForSubscription;
public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public price: number;
public priceId: string;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -43,14 +50,27 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private stripeService: StripeService,
private userService: UserService,
public webAuthnService: WebAuthnService
) {
this.dataService
.fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currencies }) => {
.subscribe(({ currencies, globalPermissions, subscriptions }) => {
this.coupon = subscriptions?.[0]?.coupon;
this.couponId = subscriptions?.[0]?.couponId;
this.currencies = currencies;
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
this.price = subscriptions?.[0]?.price;
this.priceId = subscriptions?.[0]?.priceId;
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged
@ -64,6 +84,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
permissions.updateUserSettings
);
this.hasPermissionToUpdateViewMode = hasPermission(
this.user.permissions,
permissions.updateViewMode
);
this.changeDetectorRef.markForCheck();
}
});
@ -99,6 +124,23 @@ export class AccountPageComponent implements OnDestroy, OnInit {
});
}
public onCheckout() {
this.dataService
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
.pipe(
switchMap(({ sessionId }: { sessionId: string }) => {
return this.stripeService.redirectToCheckout({
sessionId
});
})
)
.subscribe((result) => {
if (result.error) {
alert(result.error.message);
}
});
}
public onSignInWithFingerprintChange(aEvent: MatSlideToggleChange) {
if (aEvent.checked) {
this.registerDevice();
@ -124,6 +166,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.webAuthnService
.deregister()
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.update();
@ -139,6 +182,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.webAuthnService
.register()
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.update();

View File

@ -1,10 +1,7 @@
<div class="container">
<div class="row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3">
<ng-container *ngIf="user?.alias">{{ user.alias }}</ng-container>
<ng-container *ngIf="!user?.alias" i18n>Account</ng-container>
</h3>
<h3 class="d-flex justify-content-center mb-3" i18n>Account</h3>
</div>
</div>
<div *ngIf="user?.settings" class="mb-5 row">
@ -19,52 +16,89 @@
<div class="w-50" i18n>Membership</div>
<div class="w-50">
<div class="align-items-center d-flex mb-1">
{{ user.subscription.type }}
{{ user?.subscription?.type }}
<ion-icon
*ngIf="user?.subscription?.type === 'Premium'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
</div>
<div *ngIf="user.subscription.expiresAt">
Valid until {{ user.subscription.expiresAt | date:
<div *ngIf="user?.subscription?.expiresAt">
Valid until {{ user?.subscription?.expiresAt | date:
defaultDateFormat }}
</div>
<div *ngIf="!user.subscription.expiresAt">
<button color="primary" disabled i18n mat-flat-button>
<div
*ngIf="hasPermissionForSubscription && !user?.subscription?.expiresAt"
>
<button
color="primary"
i18n
mat-flat-button
(click)="onCheckout(priceId)"
>
Upgrade
</button>
<div *ngIf="price" class="mt-1">
{{ baseCurrency }}
<ng-container *ngIf="coupon"
>{{ price - coupon | number : '1.2-2' }}
<del>{{ price }}</del>
</ng-container>
<ng-container *ngIf="!coupon">{{ price }}</ng-container>
<span i18n> per year</span>
</div>
</div>
</div>
</div>
<div class="d-flex mt-4 py-1">
<div class="pt-4 w-50" i18n>Settings</div>
<div class="w-50">
<form #changeUserSettingsForm="ngForm">
<mat-form-field appearance="outline" class="mb-3 w-100">
<mat-label i18n>Base Currency</mat-label>
<mat-select
name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency"
(selectionChange)="onChangeUserSettings('baseCurrency', $event.value)"
>
<mat-option
*ngFor="let currency of currencies"
[value]="currency"
>{{ currency }}</mat-option
<form #changeUserSettingsForm="ngForm" class="w-100">
<div class="d-flex mb-2">
<div class="align-items-center d-flex pt-1 w-50" i18n>
Base Currency
</div>
<div class="w-50">
<mat-form-field appearance="outline" class="w-100">
<mat-select
name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency"
(selectionChange)="onChangeUserSettings('baseCurrency', $event.value)"
>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>View Mode</mat-label>
<mat-select
name="viewMode"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.viewMode"
(selectionChange)="onChangeUserSettings('viewMode', $event.value)"
>
<mat-option value="DEFAULT">Default</mat-option>
<mat-option value="ZEN">Zen</mat-option>
</mat-select>
</mat-form-field>
</form>
</div>
<mat-option
*ngFor="let currency of currencies"
[value]="currency"
>{{ currency }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="d-flex">
<div class="align-items-center d-flex pt-1 w-50" i18n>
View Mode
<ion-icon
*ngIf="!hasPermissionToUpdateViewMode"
class="mx-1 text-muted"
name="diamond-outline"
></ion-icon>
</div>
<div class="w-50">
<div class="align-items-center d-flex overflow-hidden">
<mat-form-field appearance="outline" class="w-100">
<mat-select
name="viewMode"
[disabled]="!hasPermissionToUpdateViewMode"
[value]="user.settings.viewMode"
(selectionChange)="onChangeUserSettings('viewMode', $event.value)"
>
<mat-option value="DEFAULT">Default</mat-option>
<mat-option value="ZEN">Zen</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</div>
</form>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="w-50" i18n>Sign in with fingerprint</div>
@ -72,6 +106,7 @@
<mat-slide-toggle
#toggleSignInWithFingerprintEnabledElement
color="primary"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onSignInWithFingerprintChange($event)"
></mat-slide-toggle>
</div>

View File

@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
@ -20,7 +20,7 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/c
templateUrl: './accounts-page.html',
styleUrls: ['./accounts-page.scss']
})
export class AccountsPageComponent implements OnInit {
export class AccountsPageComponent implements OnDestroy, OnInit {
public accounts: AccountModel[];
public deviceType: string;
public hasImpersonationId: boolean;
@ -71,6 +71,7 @@ export class AccountsPageComponent implements OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
@ -98,23 +99,29 @@ export class AccountsPageComponent implements OnInit {
}
public fetchAccounts() {
this.dataService.fetchAccounts().subscribe((response) => {
this.accounts = response;
this.dataService
.fetchAccounts()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.accounts = response;
if (this.accounts?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
if (this.accounts?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
});
}
public onDeleteAccount(aId: string) {
this.dataService.deleteAccount(aId).subscribe({
next: () => {
this.fetchAccounts();
}
});
this.dataService
.deleteAccount(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchAccounts();
}
});
}
public onUpdateAccount(aAccount: AccountModel) {
@ -125,6 +132,8 @@ export class AccountsPageComponent implements OnInit {
public openUpdateAccountDialog({
accountType,
balance,
currency,
id,
name,
platformId
@ -133,6 +142,8 @@ export class AccountsPageComponent implements OnInit {
data: {
account: {
accountType,
balance,
currency,
id,
name,
platformId
@ -142,19 +153,25 @@ export class AccountsPageComponent implements OnInit {
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe((data: any) => {
const account: UpdateAccountDto = data?.account;
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => {
const account: UpdateAccountDto = data?.account;
if (account) {
this.dataService.putAccount(account).subscribe({
next: () => {
this.fetchAccounts();
}
});
}
if (account) {
this.dataService
.putAccount(account)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchAccounts();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() {
@ -167,6 +184,8 @@ export class AccountsPageComponent implements OnInit {
data: {
account: {
accountType: AccountType.SECURITIES,
balance: 0,
currency: this.user?.settings?.baseCurrency,
name: null,
platformId: null
}
@ -175,18 +194,24 @@ export class AccountsPageComponent implements OnInit {
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe((data: any) => {
const account: CreateAccountDto = data?.account;
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => {
const account: CreateAccountDto = data?.account;
if (account) {
this.dataService.postAccount(account).subscribe({
next: () => {
this.fetchAccounts();
}
});
}
if (account) {
this.dataService
.postAccount(account)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchAccounts();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
this.router.navigate(['.'], { relativeTo: this.route });
});
}
}

View File

@ -6,7 +6,7 @@ import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-ta
import { AccountsPageRoutingModule } from './accounts-page-routing.module';
import { AccountsPageComponent } from './accounts-page.component';
import { CreateOrUpdateAccountDialogModule } from './create-or-update-account-dialog/create-or-update-account-dialog.module';
import { GfCreateOrUpdateAccountDialogModule } from './create-or-update-account-dialog/create-or-update-account-dialog.module';
@NgModule({
declarations: [AccountsPageComponent],
@ -14,8 +14,8 @@ import { CreateOrUpdateAccountDialogModule } from './create-or-update-account-di
imports: [
AccountsPageRoutingModule,
CommonModule,
CreateOrUpdateAccountDialogModule,
GfAccountsTableModule,
GfCreateOrUpdateAccountDialogModule,
MatButtonModule,
RouterModule
],

View File

@ -1,7 +1,14 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Currency } from '@prisma/client';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { DataService } from '../../../services/data.service';
import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
@ -13,23 +20,29 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-account-dialog.scss'],
templateUrl: 'create-or-update-account-dialog.html'
})
export class CreateOrUpdateAccountDialog {
export class CreateOrUpdateAccountDialog implements OnDestroy {
public currencies: Currency[] = [];
public platforms: { id: string; name: string }[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<CreateOrUpdateAccountDialog>,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccountDialogParams
) {}
ngOnInit() {
this.dataService.fetchInfo().subscribe(({ currencies, platforms }) => {
this.currencies = currencies;
this.platforms = platforms;
});
this.dataService
.fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currencies, platforms }) => {
this.currencies = currencies;
this.platforms = platforms;
this.changeDetectorRef.markForCheck();
});
}
public onCancel(): void {

View File

@ -1,6 +1,6 @@
<form #addAccountForm="ngForm" class="d-flex flex-column h-100">
<h1 *ngIf="data.account.id" mat-dialog-title i18n>Update account</h1>
<h1 *ngIf="!data.account.id" mat-dialog-title i18n>Add account</h1>
<h1 *ngIf="data.account.id" i18n mat-dialog-title>Update account</h1>
<h1 *ngIf="!data.account.id" i18n mat-dialog-title>Add account</h1>
<div class="flex-grow-1" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
@ -8,14 +8,37 @@
<input matInput name="name" required [(ngModel)]="data.account.name" />
</mat-form-field>
</div>
<div class="d-none">
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.account.accountType">
<mat-option value="SECURITIES" i18n> SECURITIES </mat-option>
<mat-option value="CASH" i18n>Cash</mat-option>
<mat-option value="SECURITIES" i18n>Securities</mat-option>
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label>
<mat-select name="currency" required [(value)]="data.account.currency">
<mat-option *ngFor="let currency of currencies" [value]="currency"
>{{ currency }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Balance</mat-label>
<input
matInput
name="balance"
required
type="number"
[(ngModel)]="data.account.balance"
/>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Platform</mat-label>

View File

@ -24,4 +24,4 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
],
providers: []
})
export class CreateOrUpdateAccountDialogModule {}
export class GfCreateOrUpdateAccountDialogModule {}

View File

@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
@ -19,7 +19,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-page.html',
styleUrls: ['./admin-page.scss']
})
export class AdminPageComponent implements OnInit {
export class AdminPageComponent implements OnDestroy, OnInit {
public dataGatheringInProgress: boolean;
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public exchangeRates: { label1: string; label2: string; value: number }[];
@ -58,11 +58,14 @@ export class AdminPageComponent implements OnInit {
}
public onFlushCache() {
this.cacheService.flush().subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
this.cacheService
.flush()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onGatherMax() {
@ -71,11 +74,14 @@ export class AdminPageComponent implements OnInit {
);
if (confirmation === true) {
this.adminService.gatherMax().subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
this.adminService
.gatherMax()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
}
@ -98,11 +104,14 @@ export class AdminPageComponent implements OnInit {
const confirmation = confirm('Do you really want to delete this user?');
if (confirmation) {
this.dataService.deleteUser(aId).subscribe({
next: () => {
this.fetchAdminData();
}
});
this.dataService
.deleteUser(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchAdminData();
}
});
}
}

View File

@ -1,19 +1,28 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-auth-page',
templateUrl: './auth-page.html',
styleUrls: ['./auth-page.scss']
})
export class AuthPageComponent implements OnInit {
export class AuthPageComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService
) {}
@ -21,11 +30,21 @@ export class AuthPageComponent implements OnInit {
* Initializes the controller
*/
public ngOnInit() {
this.route.params.subscribe((params) => {
const jwt = params['jwt'];
this.tokenStorageService.saveToken(jwt);
this.route.params
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
const jwt = params['jwt'];
this.tokenStorageService.saveToken(
jwt,
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
);
this.router.navigate(['/']);
});
this.router.navigate(['/']);
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -116,6 +116,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
@ -148,9 +149,12 @@ export class HomePageComponent implements OnDestroy, OnInit {
width: '50rem'
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private update() {
@ -161,6 +165,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.dataService
.fetchChart({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((chartData) => {
this.historicalDataItems = chartData.map((chartDataItem) => {
return {
@ -174,6 +179,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.dataService
.fetchPortfolioPerformance({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.performance = response;
this.isLoadingPerformance = false;
@ -181,15 +187,19 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
});
this.dataService.fetchPortfolioOverview().subscribe((response) => {
this.overview = response;
this.isLoadingOverview = false;
this.dataService
.fetchPortfolioOverview()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.overview = response;
this.isLoadingOverview = false;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchPortfolioPositions({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.positions = response;
this.hasPositions =

View File

@ -6,6 +6,7 @@ import { TokenStorageService } from '@ghostfolio/client/services/token-storage.s
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { format } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-landing-page',
@ -26,21 +27,23 @@ export class LandingPageComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private router: Router,
private tokenStorageService: TokenStorageService,
private webAuthnService: WebAuthnService
private tokenStorageService: TokenStorageService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.dataService.fetchInfo().subscribe(({ demoAuthToken }) => {
this.demoAuthToken = demoAuthToken;
this.dataService
.fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ demoAuthToken }) => {
this.demoAuthToken = demoAuthToken;
this.initializeLineChart();
this.initializeLineChart();
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
});
}
public initializeLineChart() {
@ -257,7 +260,7 @@ export class LandingPageComponent implements OnDestroy, OnInit {
}
public setToken(aToken: string) {
this.tokenStorageService.saveToken(aToken);
this.tokenStorageService.saveToken(aToken, true);
this.router.navigate(['/']);
}

View File

@ -1,16 +1,29 @@
<div class="container">
<div class="row">
<div
class="align-items-center d-flex flex-column justify-content-center mb-4 w-100"
class="
align-items-center
d-flex
flex-column
justify-content-center
mb-4
w-100
"
>
<gf-logo size="large"></gf-logo>
<p class="lead m-0">Open Source Portfolio Tracker</p>
<p class="lead m-0">Wealth Management Software</p>
</div>
</div>
<div class="button-container row">
<div
class="align-items-center col d-flex justify-content-center position-relative"
class="
align-items-center
col
d-flex
justify-content-center
position-relative
"
>
<div class="py-5 text-center">
<a
@ -51,10 +64,9 @@
<strong>personal investment strategy</strong>.
</h2>
<p class="lead">
<strong>Ghostfolio</strong> empowers busy folks to have a sharp look of
their financial assets and to make solid, data-driven investment
decisions by evaluating automated
<strong>Static Portfolio Analysis Rules</strong>.
<strong>Ghostfolio</strong> empowers busy people to keep track of their
wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven
investment decisions.
</p>
</div>
</div>

View File

@ -3,7 +3,6 @@
.button-container {
aspect-ratio: 16 / 9;
margin-top: -4rem;
gf-line-chart {
bottom: 0;

View File

@ -1,4 +1,5 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
@ -10,9 +11,11 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './pricing-page.html',
styleUrls: ['./pricing-page.scss']
})
export class PricingPageComponent implements OnInit {
export class PricingPageComponent implements OnDestroy, OnInit {
public baseCurrency = baseCurrency;
public coupon: number;
public isLoggedIn: boolean;
public price: number;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -22,8 +25,19 @@ export class PricingPageComponent implements OnInit {
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private userService: UserService
) {}
) {
this.dataService
.fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ subscriptions }) => {
this.coupon = this.price = subscriptions?.[0]?.coupon;
this.price = subscriptions?.[0]?.price;
this.changeDetectorRef.markForCheck();
});
}
/**
* Initializes the controller

View File

@ -176,11 +176,17 @@
</ul>
</div>
<p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p>
<p class="h5 text-right">
<p class="h5 text-right" [hidden]="!price">
<span class="font-weight-normal"
>{{ user?.settings.baseCurrency || baseCurrency }}
<strong>0.00</strong>
<del class="ml-1 text-muted">3.99</del> / Month</span
>{{ baseCurrency }}
<ng-container *ngIf="coupon"
><strong>{{ price - coupon | number : '1.2-2' }} </strong>
<del>{{ price }}</del>
</ng-container>
<ng-container *ngIf="!coupon"
><strong>{{ price }}</strong></ng-container
>
<span i18n> per year</span></span
>
</p>
</mat-card>
@ -188,6 +194,13 @@
</div>
</div>
</div>
<div *ngIf="user?.subscription?.type === 'Basic'" class="row">
<div class="col mt-3 text-center">
<a color="primary" i18n mat-flat-button [routerLink]="['/account']">
Upgrade Plan
</a>
</div>
</div>
<div *ngIf="!user" class="row">
<div class="col mt-3 text-center">
<a color="primary" i18n mat-flat-button [routerLink]="['/register']">

View File

@ -43,6 +43,7 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
public ngOnInit() {
this.dataService
.fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ demoAuthToken, globalPermissions }) => {
this.demoAuthToken = demoAuthToken;
this.hasPermissionForSocialLogin = hasPermission(
@ -76,19 +77,16 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
width: '30rem'
});
dialogRef.afterClosed().subscribe((data) => {
if (data?.authToken) {
this.tokenStorageService.saveToken(authToken);
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
if (data?.authToken) {
this.tokenStorageService.saveToken(authToken, true);
this.router.navigate(['/']);
}
});
}
public setToken(aToken: string) {
this.tokenStorageService.saveToken(aToken);
this.router.navigate(['/']);
this.router.navigate(['/']);
}
});
}
public ngOnDestroy() {

View File

@ -2,7 +2,7 @@
<div class="row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
Create your Ghostfolio account
Create your Account
</h3>
<mat-card class="mb-4">
<mat-card-content class="text-center">

View File

@ -29,16 +29,19 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
[code: string]: { name: string; value: number };
};
public deviceType: string;
public hasImpersonationId: boolean;
public period = 'current';
public periodOptions: ToggleOption[] = [
{ label: 'Initial', value: 'original' },
{ label: 'Current', value: 'current' }
];
public hasImpersonationId: boolean;
public portfolioItems: PortfolioItem[];
public portfolioPositions: { [symbol: string]: PortfolioPosition };
public positions: { [symbol: string]: any };
public positionsArray: PortfolioPosition[];
public sectors: {
[name: string]: { name: string; value: number };
};
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -118,13 +121,17 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
};
this.positions = {};
this.positionsArray = [];
this.sectors = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
}
};
for (const [symbol, position] of Object.entries(aPortfolioPositions)) {
this.positions[symbol] = {
currency: position.currency,
exchange: position.exchange,
industry: position.industry,
sector: position.sector,
type: position.type,
value:
aPeriod === 'original'
@ -188,6 +195,30 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value;
}
if (position.sectors.length > 0) {
for (const sector of position.sectors) {
const { name, weight } = sector;
if (this.sectors[name]?.value) {
this.sectors[name].value += weight * position.value;
} else {
this.sectors[name] = {
name,
value:
weight *
(aPeriod === 'original'
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value)
};
}
}
} else {
this.sectors[UNKNOWN_KEY].value +=
aPeriod === 'original'
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value;
}
}
}

View File

@ -58,52 +58,6 @@
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Sector</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
[options]="periodOptions"
(change)="onChangePeriod($event.value)"
></gf-toggle>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="sector"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true"
[locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="positions"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Industry</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
[options]="periodOptions"
(change)="onChangePeriod($event.value)"
></gf-toggle>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="industry"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true"
[locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="positions"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
@ -148,6 +102,29 @@
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Sector</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
[options]="periodOptions"
(change)="onChangePeriod($event.value)"
></gf-toggle>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="name"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="sectors"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
@ -215,7 +192,7 @@
</mat-card>
</div>
</div>
<div class="row">
<div class="investment-chart row">
<div class="col-lg">
<mat-card class="mb-3">
<mat-card-header>

View File

@ -1,4 +1,16 @@
:host {
.investment-chart {
.mat-card {
.mat-card-content {
aspect-ratio: 16 / 9;
gf-investment-chart {
height: 100%;
}
}
}
}
.proportion-charts {
.mat-card {
.mat-card-content {

View File

@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
@ -9,7 +9,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './report-page.html',
styleUrls: ['./report-page.scss']
})
export class ReportPageComponent implements OnInit {
export class ReportPageComponent implements OnDestroy, OnInit {
public accountClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[];

View File

@ -2,7 +2,8 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject
Inject,
OnDestroy
} from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
@ -23,12 +24,12 @@ import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces';
@Component({
host: { class: 'h-100' },
selector: 'create-or-update-transaction-dialog',
selector: 'gf-create-or-update-transaction-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./create-or-update-transaction-dialog.scss'],
templateUrl: 'create-or-update-transaction-dialog.html'
})
export class CreateOrUpdateTransactionDialog {
export class CreateOrUpdateTransactionDialog implements OnDestroy {
public currencies: Currency[] = [];
public currentMarketPrice = null;
public filteredLookupItems: Observable<LookupItem[]>;
@ -49,10 +50,15 @@ export class CreateOrUpdateTransactionDialog {
) {}
ngOnInit() {
this.dataService.fetchInfo().subscribe(({ currencies, platforms }) => {
this.currencies = currencies;
this.platforms = platforms;
});
this.dataService
.fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currencies, platforms }) => {
this.currencies = currencies;
this.platforms = platforms;
this.changeDetectorRef.markForCheck();
});
this.filteredLookupItems = this.searchSymbolCtrl.valueChanges.pipe(
startWith(''),
@ -73,6 +79,7 @@ export class CreateOrUpdateTransactionDialog {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => {
this.currentMarketPrice = marketPrice;
this.changeDetectorRef.markForCheck();
});
}

View File

@ -2,6 +2,22 @@
<h1 *ngIf="data.transaction.id" mat-dialog-title i18n>Update transaction</h1>
<h1 *ngIf="!data.transaction.id" mat-dialog-title i18n>Add transaction</h1>
<div class="flex-grow-1" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Account</mat-label>
<mat-select
name="accountId"
required
[(value)]="data.transaction.accountId"
>
<mat-option
*ngFor="let account of data.user?.accounts"
[value]="account.id"
>{{ account.name }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Symbol or ISIN</mat-label>
@ -42,7 +58,7 @@
</mat-select>
</mat-form-field>
</div>
<div>
<div class="d-none">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label>
<mat-select
@ -136,31 +152,25 @@
</button>
</mat-form-field>
</div>
</div>
<div class="d-flex" mat-dialog-actions>
<gf-value
class="flex-grow-1"
[currency]="data.transaction.currency"
[locale]="data.user?.settings?.locale"
[value]="data.transaction.fee + (data.transaction.quantity * data.transaction.unitPrice)"
></gf-value>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Account</mat-label>
<mat-select
name="accountId"
required
[(value)]="data.transaction.accountId"
>
<mat-option *ngFor="let account of data.accounts" [value]="account.id"
>{{ account.name }}</mat-option
>
</mat-select>
</mat-form-field>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!(addTransactionForm.form.valid && data.transaction.symbol)"
[mat-dialog-close]="data"
>
Save
</button>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!(addTransactionForm.form.valid && data.transaction.symbol)"
[mat-dialog-close]="data"
>
Save
</button>
</div>
</form>

View File

@ -9,6 +9,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { GfValueModule } from '@ghostfolio/client/components/value/value.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog.component';
@ -19,6 +20,7 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
imports: [
CommonModule,
GfSymbolModule,
GfValueModule,
FormsModule,
MatAutocompleteModule,
MatButtonModule,

View File

@ -1,6 +1,12 @@
:host {
display: block;
.mat-dialog-actions {
gf-value {
font-size: 0.9rem;
}
}
.mat-dialog-content {
max-height: unset;

View File

@ -1,7 +1,8 @@
import { User } from '@ghostfolio/common/interfaces';
import { Account, Order } from '@prisma/client';
export interface CreateOrUpdateTransactionDialogParams {
accountId: string;
accounts: Account[];
transaction: Order;
user: User;
}

View File

@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
@ -9,6 +9,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -20,7 +21,7 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
templateUrl: './transactions-page.html',
styleUrls: ['./transactions-page.scss']
})
export class TransactionsPageComponent implements OnInit {
export class TransactionsPageComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
@ -71,6 +72,7 @@ export class TransactionsPageComponent implements OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
@ -98,15 +100,18 @@ export class TransactionsPageComponent implements OnInit {
}
public fetchOrders() {
this.dataService.fetchOrders().subscribe((response) => {
this.transactions = response;
this.dataService
.fetchOrders()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.transactions = response;
if (this.transactions?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
if (this.transactions?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
});
}
public onCloneTransaction(aTransaction: OrderModel) {
@ -114,11 +119,30 @@ export class TransactionsPageComponent implements OnInit {
}
public onDeleteTransaction(aId: string) {
this.dataService.deleteOrder(aId).subscribe({
next: () => {
this.fetchOrders();
}
});
this.dataService
.deleteOrder(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchOrders();
}
});
}
public onExport() {
this.dataService
.fetchExport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
this.downloadAsFile(
data,
`ghostfolio-export-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
'text/plain'
);
});
}
public onUpdateTransaction(aTransaction: OrderModel) {
@ -141,7 +165,6 @@ export class TransactionsPageComponent implements OnInit {
}: OrderModel): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
data: {
accounts: this.user.accounts,
transaction: {
accountId,
currency,
@ -153,25 +176,32 @@ export class TransactionsPageComponent implements OnInit {
symbol,
type,
unitPrice
}
},
user: this.user
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe((data: any) => {
const transaction: UpdateOrderDto = data?.transaction;
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => {
const transaction: UpdateOrderDto = data?.transaction;
if (transaction) {
this.dataService.putOrder(transaction).subscribe({
next: () => {
this.fetchOrders();
}
});
}
if (transaction) {
this.dataService
.putOrder(transaction)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchOrders();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() {
@ -179,10 +209,23 @@ export class TransactionsPageComponent implements OnInit {
this.unsubscribeSubject.complete();
}
private downloadAsFile(
aContent: unknown,
aFileName: string,
aContentType: string
) {
const a = document.createElement('a');
const file = new Blob([JSON.stringify(aContent, undefined, ' ')], {
type: aContentType
});
a.href = URL.createObjectURL(file);
a.download = aFileName;
a.click();
}
private openCreateTransactionDialog(aTransaction?: OrderModel): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
data: {
accounts: this.user?.accounts,
transaction: {
accountId:
aTransaction?.accountId ??
@ -197,24 +240,28 @@ export class TransactionsPageComponent implements OnInit {
symbol: aTransaction?.symbol ?? null,
type: aTransaction?.type ?? 'BUY',
unitPrice: null
}
},
user: this.user
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe((data: any) => {
const transaction: CreateOrderDto = data?.transaction;
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => {
const transaction: CreateOrderDto = data?.transaction;
if (transaction) {
this.dataService.postOrder(transaction).subscribe({
next: () => {
this.fetchOrders();
}
});
}
if (transaction) {
this.dataService.postOrder(transaction).subscribe({
next: () => {
this.fetchOrders();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
this.router.navigate(['.'], { relativeTo: this.route });
});
}
}

View File

@ -8,6 +8,7 @@
[locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteOrder"
[transactions]="transactions"
(export)="onExport()"
(transactionDeleted)="onDeleteTransaction($event)"
(transactionToClone)="onCloneTransaction($event)"
(transactionToUpdate)="onUpdateTransaction($event)"

View File

@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { WebauthnPageComponent } from '@ghostfolio/client/pages/webauthn/webauthn-page.component';
const routes: Routes = [{ path: '', component: WebauthnPageComponent }];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class WebauthnPageRoutingModule {}

View File

@ -0,0 +1,61 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-webauthn-page',
templateUrl: './webauthn-page.html',
styleUrls: ['./webauthn-page.scss']
})
export class WebauthnPageComponent implements OnDestroy, OnInit {
public hasError = false;
private unsubscribeSubject = new Subject<void>();
constructor(
private changeDetectorRef: ChangeDetectorRef,
private router: Router,
private tokenStorageService: TokenStorageService,
private webAuthnService: WebAuthnService
) {}
public ngOnInit() {
this.signIn();
}
public deregisterDevice() {
this.webAuthnService
.deregister()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['/']);
});
}
public signIn() {
this.hasError = false;
this.webAuthnService
.login()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({ authToken }) => {
this.tokenStorageService.saveToken(authToken, false);
this.router.navigate(['/']);
},
(error) => {
console.error(error);
this.hasError = true;
this.changeDetectorRef.markForCheck();
}
);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,36 @@
<div class="container">
<div class="row">
<div class="col">
<div
class="align-items-center d-flex flex-column justify-content-center mb-4 w-100"
>
<gf-logo size="medium"></gf-logo>
</div>
<div *ngIf="!hasError" class="col d-flex justify-content-center">
<mat-spinner [diameter]="20"></mat-spinner>
</div>
<div
*ngIf="hasError"
class="align-items-center col d-flex flex-column justify-content-center"
>
<h1 class="d-flex h5 justify-content-center mb-0 text-center" i18n>
Oops, authentication has failed.
</h1>
<button
class="mb-3 mt-4"
color="primary"
i18n
mat-flat-button
(click)="signIn()"
>
Try again
</button>
<div class="text-muted" i18n>or</div>
<button class="mt-1" i18n mat-flat-button (click)="deregisterDevice()">
Go back to Home Page
</button>
</div>
</div>
</div>
</div>

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