Compare commits
71 Commits
Author | SHA1 | Date | |
---|---|---|---|
5c0f710563 | |||
1c65599a16 | |||
61e667213e | |||
ba47212057 | |||
f0c6517019 | |||
80ba112bc0 | |||
40696b425e | |||
6dbdf23a68 | |||
cdcbe3ab71 | |||
6996e5a140 | |||
be8d60968d | |||
d53e5c4da5 | |||
a3a9957196 | |||
9072cbdba1 | |||
120b691336 | |||
bd4ad76953 | |||
94d56f553f | |||
ecdd325228 | |||
51fbc538ca | |||
39a76f7f40 | |||
e4d325daab | |||
b765df65d6 | |||
c7b7efae3b | |||
be5b58f49a | |||
91c748c7ad | |||
ecfe694f0b | |||
1491bf7f76 | |||
b3b9a051c3 | |||
bf1146bfd6 | |||
0774ca91a1 | |||
f403807f2d | |||
f22991b090 | |||
1135a5b335 | |||
d9ea255c17 | |||
2c19d8c8e7 | |||
db090229ce | |||
fbe590ddb9 | |||
0d65136a9e | |||
dea87cc3cf | |||
a062a3cee4 | |||
5b1b207a6f | |||
63cc7b2871 | |||
3986e8f879 | |||
290e93bbd7 | |||
b08ecd1b18 | |||
92d321a001 | |||
ce2d8d519d | |||
f32bef071e | |||
4aa7365d9b | |||
367f25a975 | |||
9832334da1 | |||
e126f9ec54 | |||
09bbda3502 | |||
ee9a521813 | |||
169c151547 | |||
3a95ec0f81 | |||
ad00cd9d81 | |||
373a2015c0 | |||
66c955ad6c | |||
a2440fc067 | |||
3d7624d997 | |||
0264b592b9 | |||
198eaf57d3 | |||
6783ea2ebb | |||
a35701fe24 | |||
5db90f1787 | |||
81fe538484 | |||
51884913be | |||
8886082dfa | |||
3b12e5b85b | |||
6c1119caec |
163
CHANGELOG.md
163
CHANGELOG.md
@ -5,6 +5,169 @@ 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.30.0 - 31.07.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the date range component to the positions tab
|
||||
- Added a blog
|
||||
|
||||
## 1.29.0 - 26.07.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Introduced tabs on the home page
|
||||
- Changed the menu icon if the menu is open on mobile
|
||||
|
||||
## 1.28.0 - 24.07.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the data management by symbol profile data
|
||||
- Added a currency attribute to the symbol profile model
|
||||
- Added a positions button on the home page which scrolls into the view
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the style of the active page in the navigation on desktop
|
||||
- Removed the footer for users
|
||||
- Extended the _Zen Mode_ by positions
|
||||
- Improved the _Create Account_ message in the _Live Demo_
|
||||
|
||||
## 1.27.0 - 18.07.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the onboarding
|
||||
- Flow of creating a new account
|
||||
- Info message to add the first transaction
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the chart on the landing page
|
||||
- Fixed the url to the _Fear & Greed Index_ on the resources page
|
||||
|
||||
## 1.26.0 - 17.07.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the import functionality for transactions
|
||||
- Added the `robots.txt` file
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styling of the current pricing plan
|
||||
- Improved the styling of the transaction type badge
|
||||
- Set the public _Stripe_ key dynamically
|
||||
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the warn color (button) of the theme
|
||||
|
||||
## 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
|
||||
|
37
README.md
37
README.md
@ -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 **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering 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)
|
||||
|
17
angular.json
17
angular.json
@ -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",
|
||||
{
|
||||
@ -104,6 +103,11 @@
|
||||
"input": "",
|
||||
"output": "./"
|
||||
},
|
||||
{
|
||||
"glob": "robots.txt",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./"
|
||||
},
|
||||
{
|
||||
"glob": "sitemap.xml",
|
||||
"input": "apps/client/src/assets",
|
||||
@ -121,7 +125,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 +162,8 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
|
@ -11,5 +11,6 @@ module.exports = {
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/api',
|
||||
testTimeout: 10000
|
||||
testTimeout: 10000,
|
||||
testEnvironment: 'node'
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
||||
|
@ -0,0 +1,6 @@
|
||||
import { Account } from '@prisma/client';
|
||||
|
||||
export interface CashDetails {
|
||||
accounts: Account[];
|
||||
balance: number;
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -61,8 +61,29 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataGatheringService.gatherProfileData();
|
||||
this.dataGatheringService.gatherMax();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@Post('gather/profile-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherProfileData(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherProfileData();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -23,10 +23,13 @@ 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 { ImportModule } from './import/import.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 +43,8 @@ import { UserModule } from './user/user.module';
|
||||
CacheModule,
|
||||
ConfigModule.forRoot(),
|
||||
ExperimentalModule,
|
||||
ExportModule,
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
OrderModule,
|
||||
PortfolioModule,
|
||||
@ -59,6 +64,7 @@ import { UserModule } from './user/user.module';
|
||||
rootPath: join(__dirname, '..', 'client'),
|
||||
exclude: ['/api*']
|
||||
}),
|
||||
SubscriptionModule,
|
||||
SymbolModule,
|
||||
UserModule
|
||||
],
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
23
apps/api/src/app/export/export.controller.ts
Normal file
23
apps/api/src/app/export/export.controller.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
32
apps/api/src/app/export/export.module.ts
Normal file
32
apps/api/src/app/export/export.module.ts
Normal 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 {}
|
31
apps/api/src/app/export/export.service.ts
Normal file
31
apps/api/src/app/export/export.service.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
7
apps/api/src/app/import/import-data.dto.ts
Normal file
7
apps/api/src/app/import/import-data.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Order } from '@prisma/client';
|
||||
import { IsArray } from 'class-validator';
|
||||
|
||||
export class ImportDataDto {
|
||||
@IsArray()
|
||||
orders: Partial<Order>[];
|
||||
}
|
50
apps/api/src/app/import/import.controller.ts
Normal file
50
apps/api/src/app/import/import.controller.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpException,
|
||||
Inject,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { ImportDataDto } from './import-data.dto';
|
||||
import { ImportService } from './import.service';
|
||||
|
||||
@Controller('import')
|
||||
export class ImportController {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly importService: ImportService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async import(@Body() importData: ImportDataDto): Promise<void> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.importService.import({
|
||||
orders: importData.orders,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
34
apps/api/src/app/import/import.module.ts
Normal file
34
apps/api/src/app/import/import.module.ts
Normal file
@ -0,0 +1,34 @@
|
||||
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 { 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 { ImportController } from './import.controller';
|
||||
import { ImportService } from './import.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
controllers: [ImportController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
ImportService,
|
||||
OrderService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
})
|
||||
export class ImportModule {}
|
43
apps/api/src/app/import/import.service.ts
Normal file
43
apps/api/src/app/import/import.service.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Order } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
public constructor(private readonly orderService: OrderService) {}
|
||||
|
||||
public async import({
|
||||
orders,
|
||||
userId
|
||||
}: {
|
||||
orders: Partial<Order>[];
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
for (const {
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
} of orders) {
|
||||
await this.orderService.createOrder(
|
||||
{
|
||||
currency,
|
||||
dataSource,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice,
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
User: { connect: { id: userId } }
|
||||
},
|
||||
userId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
@ -17,6 +20,7 @@ export class InfoService {
|
||||
) {}
|
||||
|
||||
public async get(): Promise<InfoItem> {
|
||||
const info: Partial<InfoItem> = {};
|
||||
const platforms = await this.prisma.platform.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true }
|
||||
@ -24,23 +28,87 @@ export class InfoService {
|
||||
|
||||
const globalPermissions: string[] = [];
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_BLOG')) {
|
||||
globalPermissions.push(permissions.enableBlog);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
globalPermissions.push(permissions.enableImport);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
|
||||
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);
|
||||
|
||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||
}
|
||||
|
||||
return {
|
||||
...info,
|
||||
globalPermissions,
|
||||
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 +122,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 [];
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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: {
|
||||
|
@ -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,18 @@ 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
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherProfileData([data.symbol]);
|
||||
|
||||
await this.cacheService.flush(aUserId);
|
||||
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
57
apps/api/src/app/subscription/subscription.controller.ts
Normal file
57
apps/api/src/app/subscription/subscription.controller.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
13
apps/api/src/app/subscription/subscription.module.ts
Normal file
13
apps/api/src/app/subscription/subscription.module.ts
Normal 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 {}
|
89
apps/api/src/app/subscription/subscription.service.ts
Normal file
89
apps/api/src/app/subscription/subscription.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { Currency, ViewMode } from '@prisma/client';
|
||||
|
||||
export interface UserSettingsParams {
|
||||
currency?: Currency;
|
||||
userId: string;
|
||||
viewMode?: ViewMode;
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -1,3 +1,4 @@
|
||||
export const environment = {
|
||||
production: true
|
||||
production: true,
|
||||
version: `v${require('../../../../package.json').version}`
|
||||
};
|
||||
|
@ -1,3 +1,4 @@
|
||||
export const environment = {
|
||||
production: false
|
||||
production: false,
|
||||
version: 'dev'
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
|
||||
|
||||
import { environment } from '../environments/environment';
|
||||
import { Environment } from './interfaces/environment.interface';
|
||||
|
||||
@Injectable()
|
||||
@ -14,9 +15,12 @@ export class ConfigurationService {
|
||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||
ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }),
|
||||
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 +31,8 @@ export class ConfigurationService {
|
||||
REDIS_HOST: str({ default: 'localhost' }),
|
||||
REDIS_PORT: port({ default: 6379 }),
|
||||
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
||||
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
||||
STRIPE_SECRET_KEY: str({ default: '' }),
|
||||
WEB_AUTH_RP_ID: host({ default: 'localhost' })
|
||||
});
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ export class CronService {
|
||||
|
||||
@Cron(CronExpression.EVERY_12_HOURS)
|
||||
public async runEveryTwelveHours() {
|
||||
await this.dataGatheringService.gatherProfileData();
|
||||
await this.exchangeRateDataService.loadCurrencies();
|
||||
}
|
||||
}
|
||||
|
@ -2,12 +2,14 @@ import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
|
||||
import {
|
||||
getUtc,
|
||||
isGhostfolioScraperApiSymbol,
|
||||
isRakutenRapidApiSymbol,
|
||||
resetHours
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
differenceInHours,
|
||||
endOfToday,
|
||||
format,
|
||||
getDate,
|
||||
getMonth,
|
||||
@ -36,7 +38,7 @@ export class DataGatheringService {
|
||||
|
||||
if (isDataGatheringNeeded) {
|
||||
console.log('7d data gathering has been started.');
|
||||
console.time('data-gathering');
|
||||
console.time('7d-data-gathering');
|
||||
|
||||
await this.prisma.property.create({
|
||||
data: {
|
||||
@ -69,7 +71,7 @@ export class DataGatheringService {
|
||||
});
|
||||
|
||||
console.log('7d data gathering has been completed.');
|
||||
console.timeEnd('data-gathering');
|
||||
console.timeEnd('7d-data-gathering');
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,7 +82,7 @@ export class DataGatheringService {
|
||||
|
||||
if (!isDataGatheringLocked) {
|
||||
console.log('Max data gathering has been started.');
|
||||
console.time('data-gathering');
|
||||
console.time('max-data-gathering');
|
||||
|
||||
await this.prisma.property.create({
|
||||
data: {
|
||||
@ -113,10 +115,56 @@ export class DataGatheringService {
|
||||
});
|
||||
|
||||
console.log('Max data gathering has been completed.');
|
||||
console.timeEnd('data-gathering');
|
||||
console.timeEnd('max-data-gathering');
|
||||
}
|
||||
}
|
||||
|
||||
public async gatherProfileData(aSymbols?: string[]) {
|
||||
console.log('Profile data gathering has been started.');
|
||||
console.time('profile-data-gathering');
|
||||
|
||||
let symbols = aSymbols;
|
||||
|
||||
if (!symbols) {
|
||||
const dataGatheringItems = await this.getSymbolsProfileData();
|
||||
symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
});
|
||||
}
|
||||
|
||||
const currentData = await this.dataProviderService.get(symbols);
|
||||
|
||||
for (const [symbol, { currency, dataSource, name }] of Object.entries(
|
||||
currentData
|
||||
)) {
|
||||
try {
|
||||
await this.prisma.symbolProfile.upsert({
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
symbol
|
||||
},
|
||||
update: {
|
||||
currency,
|
||||
name
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`${symbol}: ${error?.meta?.cause}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Profile data gathering has been completed.');
|
||||
console.timeEnd('profile-data-gathering');
|
||||
}
|
||||
|
||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||
let hasError = false;
|
||||
|
||||
@ -187,7 +235,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 +273,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 +334,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 [
|
||||
@ -291,6 +350,25 @@ export class DataGatheringService {
|
||||
];
|
||||
}
|
||||
|
||||
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const distinctOrders = await this.prisma.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: { dataSource: true, symbol: true }
|
||||
});
|
||||
|
||||
return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter(
|
||||
(distinctOrder) => {
|
||||
return (
|
||||
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
|
||||
distinctOrder.dataSource !== DataSource.RAKUTEN
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async isDataGatheringNeeded() {
|
||||
const lastDataGathering = await this.prisma.property.findUnique({
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
|
@ -46,7 +46,10 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
const yahooFinanceSymbols = aSymbols.filter((symbol) => {
|
||||
return !isGhostfolioScraperApiSymbol(symbol);
|
||||
return (
|
||||
!isGhostfolioScraperApiSymbol(symbol) &&
|
||||
!isRakutenRapidApiSymbol(symbol)
|
||||
);
|
||||
});
|
||||
|
||||
const response = await this.yahooFinanceService.get(yahooFinanceSymbols);
|
||||
@ -57,13 +60,24 @@ export class DataProviderService {
|
||||
|
||||
for (const symbol of ghostfolioScraperApiSymbols) {
|
||||
if (symbol) {
|
||||
const ghostfolioScraperApiResult = await this.ghostfolioScraperApiService.get(
|
||||
[symbol]
|
||||
);
|
||||
const ghostfolioScraperApiResult =
|
||||
await this.ghostfolioScraperApiService.get([symbol]);
|
||||
response[symbol] = ghostfolioScraperApiResult[symbol];
|
||||
}
|
||||
}
|
||||
|
||||
const rakutenRapidApiSymbols = aSymbols.filter((symbol) => {
|
||||
return isRakutenRapidApiSymbol(symbol);
|
||||
});
|
||||
|
||||
for (const symbol of rakutenRapidApiSymbols) {
|
||||
if (symbol) {
|
||||
const rakutenRapidApiResult =
|
||||
await this.ghostfolioScraperApiService.get([symbol]);
|
||||
response[symbol] = rakutenRapidApiResult[symbol];
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
@ -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', '');
|
||||
};
|
||||
|
@ -5,9 +5,12 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ALPHA_VANTAGE_API_KEY: string;
|
||||
CACHE_TTL: number;
|
||||
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
|
||||
ENABLE_FEATURE_BLOG: boolean;
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||
ENABLE_FEATURE_IMPORT: boolean;
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
||||
ENABLE_FEATURE_STATISTICS: boolean;
|
||||
ENABLE_FEATURE_SUBSCRIPTION: boolean;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_SECRET: string;
|
||||
@ -18,5 +21,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
REDIS_HOST: string;
|
||||
REDIS_PORT: number;
|
||||
ROOT_URL: string;
|
||||
STRIPE_PUBLIC_KEY: string;
|
||||
STRIPE_SECRET_KEY: string;
|
||||
WEB_AUTH_RP_ID: string;
|
||||
}
|
||||
|
@ -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];
|
||||
|
@ -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' }
|
||||
};
|
||||
|
@ -33,6 +33,20 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
|
||||
},
|
||||
{
|
||||
path: 'de/blog/2021/07/hallo-ghostfolio',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
|
||||
).then((m) => m.HalloGhostfolioPageModule)
|
||||
},
|
||||
{
|
||||
path: 'en/blog/2021/07/hello-ghostfolio',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
||||
).then((m) => m.HelloGhostfolioPageModule)
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () =>
|
||||
@ -92,6 +106,13 @@ const routes: Routes = [
|
||||
(m) => m.TransactionsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'webauthn',
|
||||
loadChildren: () =>
|
||||
import('./pages/webauthn/webauthn-page.module').then(
|
||||
(m) => m.WebauthnPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'zen',
|
||||
loadChildren: () =>
|
||||
@ -112,6 +133,7 @@ const routes: Routes = [
|
||||
routes,
|
||||
// Preload all lazy loaded modules with the attribute preload === true
|
||||
{
|
||||
anchorScrolling: 'enabled',
|
||||
preloadingStrategy: ModulePreloadService,
|
||||
// enableTracing: true // <-- debugging purposes only
|
||||
relativeLinkResolution: 'legacy'
|
||||
|
@ -10,16 +10,16 @@
|
||||
|
||||
<main role="main">
|
||||
<div *ngIf="canCreateAccount" class="container create-account-container">
|
||||
<div class="row mb-5">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<a [routerLink]="['/']">
|
||||
<mat-card
|
||||
class="create-account-box p-2 text-center"
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2 text-center">
|
||||
<a class="text-center" [routerLink]="['/']">
|
||||
<div
|
||||
class="create-account-box d-inline-block px-3 py-2"
|
||||
(click)="onCreateAccount()"
|
||||
>
|
||||
<div class="mt-1" i18n>You are using the Live Demo.</div>
|
||||
<button mat-button color="primary" i18n>Create Account</button>
|
||||
</mat-card></a
|
||||
<span i18n>You are using the Live Demo.</span>
|
||||
<a class="ml-2" href="#" i18n>Create Account</a>
|
||||
</div></a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@ -28,10 +28,7 @@
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
|
||||
<footer
|
||||
*ngIf="currentRoute === 'start' || deviceType !== 'mobile'"
|
||||
class="footer d-flex justify-content-center position-absolute w-100"
|
||||
>
|
||||
<footer *ngIf="!user" class="footer d-flex justify-content-center w-100">
|
||||
<div class="container text-center">
|
||||
<div>
|
||||
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
||||
|
@ -1,21 +1,32 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
|
||||
main {
|
||||
padding: 5rem 0;
|
||||
min-height: 100vh;
|
||||
padding-top: 5rem;
|
||||
|
||||
.create-account-box {
|
||||
cursor: pointer;
|
||||
font-size: 90%;
|
||||
.create-account-container {
|
||||
height: 3.5rem;
|
||||
margin-top: -0.5rem;
|
||||
|
||||
.link {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
.create-account-box {
|
||||
background-color: rgba(0, 0, 0, $alpha-hover);
|
||||
border-radius: 2rem;
|
||||
cursor: pointer;
|
||||
font-size: 80%;
|
||||
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
bottom: 0;
|
||||
height: 5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
@ -6,7 +6,11 @@ import {
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
|
||||
import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config';
|
||||
import {
|
||||
primaryColorHex,
|
||||
secondaryColorHex,
|
||||
warnColorHex
|
||||
} from '@ghostfolio/common/config';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { MaterialCssVarsService } from 'angular-material-css-vars';
|
||||
@ -52,10 +56,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
public ngOnInit() {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.dataService.fetchInfo().subscribe((info) => {
|
||||
this.info = info;
|
||||
});
|
||||
|
||||
this.router.events
|
||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
.subscribe(() => {
|
||||
@ -63,6 +63,8 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
|
||||
const urlSegments = urlSegmentGroup.segments;
|
||||
this.currentRoute = urlSegments[0].path;
|
||||
|
||||
this.info = this.dataService.fetchInfo();
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
@ -106,5 +108,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
|
||||
this.materialCssVarsService.setAccentColor(secondaryColorHex);
|
||||
this.materialCssVarsService.setWarnColor(warnColorHex);
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,6 @@
|
||||
import { Platform } from '@angular/cdk/platform';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import {
|
||||
DateAdapter,
|
||||
MAT_DATE_FORMATS,
|
||||
@ -15,7 +13,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, STRIPE_PUBLISHABLE_KEY } 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';
|
||||
@ -25,6 +25,10 @@ import { authInterceptorProviders } from './core/auth.interceptor';
|
||||
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
|
||||
import { LanguageService } from './core/language.service';
|
||||
|
||||
export function NgxStripeFactory(): string {
|
||||
return environment.stripePublicKey;
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
imports: [
|
||||
@ -34,8 +38,6 @@ import { LanguageService } from './core/language.service';
|
||||
GfHeaderModule,
|
||||
HttpClientModule,
|
||||
MarkdownModule.forRoot(),
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MaterialCssVarsModule.forRoot({
|
||||
darkThemeClass: 'is-dark-theme',
|
||||
isAutoContrast: true,
|
||||
@ -43,7 +45,8 @@ import { LanguageService } from './core/language.service';
|
||||
}),
|
||||
MatNativeDateModule,
|
||||
MatSnackBarModule,
|
||||
NgxSkeletonLoaderModule
|
||||
NgxSkeletonLoaderModule,
|
||||
NgxStripeModule.forRoot(environment.stripePublicKey)
|
||||
],
|
||||
providers: [
|
||||
authInterceptorProviders,
|
||||
@ -54,7 +57,11 @@ import { LanguageService } from './core/language.service';
|
||||
useClass: CustomDateAdapter,
|
||||
deps: [LanguageService, MAT_DATE_LOCALE, Platform]
|
||||
},
|
||||
{ provide: MAT_DATE_FORMATS, useValue: DateFormats }
|
||||
{ provide: MAT_DATE_FORMATS, useValue: DateFormats },
|
||||
{
|
||||
provide: STRIPE_PUBLISHABLE_KEY,
|
||||
useFactory: NgxStripeFactory
|
||||
}
|
||||
],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
|
@ -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>
|
||||
|
@ -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');
|
||||
|
@ -106,6 +106,8 @@
|
||||
class="no-min-width px-1"
|
||||
mat-flat-button
|
||||
[matMenuTriggerFor]="accountMenu"
|
||||
(menuClosed)="onMenuClosed()"
|
||||
(menuOpened)="onMenuOpened()"
|
||||
>
|
||||
<ion-icon
|
||||
class="d-none d-sm-block"
|
||||
@ -114,8 +116,8 @@
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
class="d-block d-sm-none"
|
||||
name="menu-outline"
|
||||
size="large"
|
||||
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
|
||||
></ion-icon>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
|
@ -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;
|
||||
@ -17,6 +14,8 @@
|
||||
.mat-flat-button {
|
||||
&:not(.mat-primary) {
|
||||
background-color: transparent;
|
||||
text-decoration-color: rgba(var(--palette-primary-500), 1) !important;
|
||||
text-underline-offset: 0.25rem;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
@ -28,11 +27,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);
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
@ -35,6 +38,7 @@ export class HeaderComponent implements OnChanges {
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToAccessAdminControl: boolean;
|
||||
public impersonationId: string;
|
||||
public isMenuOpen: boolean;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -43,11 +47,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;
|
||||
});
|
||||
@ -80,6 +85,14 @@ export class HeaderComponent implements OnChanges {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
public onMenuClosed() {
|
||||
this.isMenuOpen = false;
|
||||
}
|
||||
|
||||
public onMenuOpened() {
|
||||
this.isMenuOpen = true;
|
||||
}
|
||||
|
||||
public onSignOut() {
|
||||
this.signOut.next();
|
||||
}
|
||||
@ -95,28 +108,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();
|
||||
}
|
||||
}
|
||||
|
@ -5,8 +5,8 @@ import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
||||
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
|
||||
|
||||
import { GfLogoModule } from '../logo/logo.module';
|
||||
import { HeaderComponent } from './header.component';
|
||||
|
||||
@NgModule({
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import 'chartjs-adapter-date-fns';
|
||||
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Input,
|
||||
OnChanges,
|
||||
@ -44,7 +45,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
public chart: Chart;
|
||||
public isLoading = true;
|
||||
|
||||
public constructor() {
|
||||
public constructor(private changeDetectorRef: ChangeDetectorRef) {
|
||||
Chart.register(
|
||||
Filler,
|
||||
LineController,
|
||||
@ -59,7 +60,12 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
|
||||
public ngOnChanges() {
|
||||
if (this.historicalDataItems) {
|
||||
this.initialize();
|
||||
setTimeout(() => {
|
||||
// Wait for the chartCanvas
|
||||
this.initialize();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,8 +85,6 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
marketPrices.push(historicalDataItem.value);
|
||||
});
|
||||
|
||||
const canvas = document.getElementById('chartCanvas');
|
||||
|
||||
const gradient = this.chartCanvas?.nativeElement
|
||||
?.getContext('2d')
|
||||
.createLinearGradient(
|
||||
@ -89,11 +93,14 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
0,
|
||||
(this.chartCanvas.nativeElement.parentNode.offsetHeight * 4) / 5
|
||||
);
|
||||
gradient.addColorStop(
|
||||
0,
|
||||
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
|
||||
);
|
||||
gradient.addColorStop(1, getBackgroundColor());
|
||||
|
||||
if (gradient) {
|
||||
gradient.addColorStop(
|
||||
0,
|
||||
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
|
||||
);
|
||||
gradient.addColorStop(1, getBackgroundColor());
|
||||
}
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -1,4 +1,4 @@
|
||||
<span class="align-items-center d-flex"
|
||||
><span class="d-inline-block logo mr-1"></span>
|
||||
<span class="name">Ghostfolio</span></span
|
||||
<span *ngIf="!hideName" class="name">Ghostfolio</span></span
|
||||
>
|
||||
|
@ -14,10 +14,12 @@ import {
|
||||
})
|
||||
export class LogoComponent implements OnInit {
|
||||
@HostBinding('class') @Input() size: 'large' | 'medium';
|
||||
@Input() hideName: boolean;
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public ngOnInit() {
|
||||
this.size = this.size || 'medium';
|
||||
this.hideName = this.hideName ?? false;
|
||||
this.size = this.size ?? 'medium';
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,13 @@
|
||||
<a
|
||||
class="align-items-center justify-content-center"
|
||||
color="primary"
|
||||
[routerLink]="['/transactions']"
|
||||
mat-button
|
||||
>
|
||||
<ion-icon class="mr-1" name="time-outline" size="large"></ion-icon>
|
||||
<span i18n>Time to add your first transaction.</span>
|
||||
</a>
|
||||
<div class="p-3">
|
||||
<div class="d-flex justify-content-center mb-1">
|
||||
<gf-logo size="large" [hideName]="true"></gf-logo>
|
||||
</div>
|
||||
<a
|
||||
class="align-items-center justify-content-center"
|
||||
color="primary"
|
||||
[routerLink]="['/transactions']"
|
||||
mat-button
|
||||
>
|
||||
<span i18n>Time to add your first transaction.</span>
|
||||
</a>
|
||||
</div>
|
||||
|
@ -1,3 +1,13 @@
|
||||
:host {
|
||||
border: 1px solid rgba(var(--dark-dividers));
|
||||
border-radius: 0.25rem;
|
||||
display: block;
|
||||
|
||||
gf-logo {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
border-color: rgba(var(--light-dividers));
|
||||
}
|
||||
|
@ -2,13 +2,14 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
|
||||
|
||||
import { NoTransactionsInfoComponent } from './no-transactions-info.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [NoTransactionsInfoComponent],
|
||||
exports: [NoTransactionsInfoComponent],
|
||||
imports: [CommonModule, MatButtonModule, RouterModule],
|
||||
imports: [CommonModule, GfLogoModule, MatButtonModule, RouterModule],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -19,7 +19,9 @@
|
||||
}
|
||||
|
||||
.mat-row {
|
||||
cursor: pointer;
|
||||
&.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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 {
|
||||
|
@ -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,45 @@
|
||||
</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
|
||||
*ngIf="hasPermissionToImportOrders"
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
(click)="onImport()"
|
||||
>
|
||||
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
|
||||
<span i18n>Import</span>
|
||||
</button>
|
||||
<button
|
||||
class="align-items-center d-flex"
|
||||
mat-menu-item
|
||||
(click)="onExport()"
|
||||
>
|
||||
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
|
||||
<span i18n>Export</span>
|
||||
</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>
|
||||
|
@ -27,7 +27,7 @@
|
||||
cursor: pointer;
|
||||
|
||||
.type-badge {
|
||||
background-color: rgba(var(--dark-primary-text), 0.05);
|
||||
background-color: rgba(var(--palette-foreground-text), 0.05);
|
||||
border-radius: 1rem;
|
||||
line-height: 1em;
|
||||
|
||||
@ -54,7 +54,10 @@
|
||||
|
||||
.mat-table {
|
||||
.type-badge {
|
||||
background-color: rgba(var(--light-primary-text), 0.1);
|
||||
background-color: rgba(
|
||||
var(--palette-foreground-text-dark),
|
||||
0.1
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,17 @@ 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() hasPermissionToImportOrders: boolean;
|
||||
@Input() locale: string;
|
||||
@Input() showActions: boolean;
|
||||
@Input() transactions: OrderWithAccount[];
|
||||
|
||||
@Output() export = new EventEmitter<void>();
|
||||
@Output() import = new EventEmitter<void>();
|
||||
@Output() transactionDeleted = new EventEmitter<string>();
|
||||
@Output() transactionToClone = new EventEmitter<OrderWithAccount>();
|
||||
@Output() transactionToUpdate = new EventEmitter<OrderWithAccount>();
|
||||
@ -54,11 +58,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 +92,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 +188,14 @@ export class TransactionsTableComponent
|
||||
}
|
||||
}
|
||||
|
||||
public onExport() {
|
||||
this.export.emit();
|
||||
}
|
||||
|
||||
public onImport() {
|
||||
this.import.emit();
|
||||
}
|
||||
|
||||
public onOpenPositionDialog({
|
||||
symbol,
|
||||
title
|
||||
@ -219,9 +236,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() {
|
||||
|
@ -16,6 +16,8 @@ import { UserService } from '../services/user/user.service';
|
||||
export class AuthGuard implements CanActivate {
|
||||
private static PUBLIC_PAGE_ROUTES = [
|
||||
'/about',
|
||||
'/de/blog',
|
||||
'/en/blog',
|
||||
'/pricing',
|
||||
'/register',
|
||||
'/resources'
|
||||
@ -40,7 +42,14 @@ 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.filter((publicPageRoute) =>
|
||||
state.url.startsWith(publicPageRoute)
|
||||
)?.length > 0
|
||||
) {
|
||||
resolve(true);
|
||||
return EMPTY;
|
||||
} else if (state.url !== '/start') {
|
||||
@ -60,6 +69,7 @@ export class AuthGuard implements CanActivate {
|
||||
) {
|
||||
this.router.navigate(['/zen']);
|
||||
resolve(false);
|
||||
return;
|
||||
} else if (state.url.startsWith('/start')) {
|
||||
if (user.settings.viewMode === ViewMode.ZEN) {
|
||||
this.router.navigate(['/zen']);
|
||||
@ -68,12 +78,14 @@ export class AuthGuard implements CanActivate {
|
||||
}
|
||||
|
||||
resolve(false);
|
||||
return;
|
||||
} else if (
|
||||
state.url.startsWith('/zen') &&
|
||||
user.settings.viewMode === ViewMode.DEFAULT
|
||||
) {
|
||||
this.router.navigate(['/home']);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(true);
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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,14 @@ 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 hasPermissionForBlog: boolean;
|
||||
public hasPermissionForStatistics: boolean;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public isLoggedIn: boolean;
|
||||
public lastPublish = environment.lastPublish;
|
||||
public statistics: Statistics;
|
||||
public user: User;
|
||||
public version = environment.version;
|
||||
|
||||
@ -26,6 +33,7 @@ export class AboutPageComponent implements OnInit {
|
||||
*/
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
@ -33,6 +41,25 @@ export class AboutPageComponent implements OnInit {
|
||||
* Initializes the controller
|
||||
*/
|
||||
public ngOnInit() {
|
||||
const { globalPermissions, statistics } = this.dataService.fetchInfo();
|
||||
|
||||
this.hasPermissionForBlog = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableBlog
|
||||
);
|
||||
|
||||
this.hasPermissionForStatistics = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableStatistics
|
||||
);
|
||||
|
||||
this.hasPermissionForSubscription = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableSubscription
|
||||
);
|
||||
|
||||
this.statistics = statistics;
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
|
@ -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 class="about-container">
|
||||
<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,8 +47,19 @@
|
||||
>
|
||||
<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
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="d-flex justify-content-center"
|
||||
>
|
||||
<div
|
||||
class="independent-and-bootstrapped-logo mb-2"
|
||||
title="Ghostfolio is an independent & bootstrapped business"
|
||||
@ -60,10 +70,92 @@
|
||||
</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 *ngIf="hasPermissionForBlog" class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="mb-3 text-center" i18n>Blog</h3>
|
||||
<mat-card class="blog-container">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap mb-3 no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
[routerLink]="['/en', 'blog', '2021', '07', 'hello-ghostfolio']"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="h6 m-0 text-truncate">Hello Ghostfolio</div>
|
||||
<div class="d-flex text-muted">31.07.2021</div>
|
||||
</div>
|
||||
<div class="align-items-center d-flex">
|
||||
<ion-icon
|
||||
class="chevron text-muted"
|
||||
name="chevron-forward-outline"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
[routerLink]="['/de', 'blog', '2021', '07', 'hallo-ghostfolio']"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="h6 m-0 text-truncate">Hallo Ghostfolio</div>
|
||||
<div class="d-flex text-muted">31.07.2021</div>
|
||||
</div>
|
||||
<div class="align-items-center d-flex">
|
||||
<ion-icon
|
||||
class="chevron text-muted"
|
||||
name="chevron-forward-outline"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</div>
|
||||
</a>
|
||||
</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 +166,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>
|
||||
|
@ -7,6 +7,18 @@
|
||||
}
|
||||
|
||||
.mat-card {
|
||||
&.about-container,
|
||||
&.changelog {
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
color: rgba(var(--palette-primary-300), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.changelog {
|
||||
::ng-deep {
|
||||
markdown {
|
||||
@ -30,15 +42,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
font-weight: bold;
|
||||
|
||||
&:hover {
|
||||
color: rgba(var(--palette-primary-300), 1);
|
||||
}
|
||||
}
|
||||
|
||||
.independent-and-bootstrapped-logo {
|
||||
background-image: url('/assets/bootstrapped-dark.svg');
|
||||
background-position: center;
|
||||
|
@ -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: boolean;
|
||||
public hasPermissionToUpdateViewMode: boolean;
|
||||
public hasPermissionToUpdateUserSettings: boolean;
|
||||
public price: number;
|
||||
public priceId: string;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -43,15 +50,23 @@ 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 }) => {
|
||||
this.currencies = currencies;
|
||||
});
|
||||
const { currencies, globalPermissions, subscriptions } =
|
||||
this.dataService.fetchInfo();
|
||||
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.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -64,6 +79,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
permissions.updateUserSettings
|
||||
);
|
||||
|
||||
this.hasPermissionToUpdateViewMode = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.updateViewMode
|
||||
);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
@ -99,6 +119,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 +161,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
this.webAuthnService
|
||||
.deregister()
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeSubject),
|
||||
catchError(() => {
|
||||
this.update();
|
||||
|
||||
@ -139,6 +177,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
this.webAuthnService
|
||||
.register()
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeSubject),
|
||||
catchError(() => {
|
||||
this.update();
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
],
|
||||
|
@ -1,4 +1,10 @@
|
||||
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';
|
||||
@ -13,23 +19,24 @@ 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;
|
||||
});
|
||||
const { currencies, platforms } = this.dataService.fetchInfo();
|
||||
|
||||
this.currencies = currencies;
|
||||
this.platforms = platforms;
|
||||
}
|
||||
|
||||
public onCancel(): void {
|
||||
|
@ -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>
|
||||
|
@ -24,4 +24,4 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class CreateOrUpdateAccountDialogModule {}
|
||||
export class GfCreateOrUpdateAccountDialogModule {}
|
||||
|
@ -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,14 +74,24 @@ 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public onGatherProfileData() {
|
||||
this.adminService
|
||||
.gatherProfileData()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public formatDistanceToNow(aDateString: string) {
|
||||
if (aDateString) {
|
||||
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
|
||||
@ -98,11 +111,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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -27,25 +27,46 @@
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2 overflow-hidden">
|
||||
<button
|
||||
class="mb-2 mr-2 mw-100"
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
(click)="onFlushCache()"
|
||||
>
|
||||
<ion-icon class="mr-1" name="close-circle-outline"></ion-icon>
|
||||
<span i18n>Reset Data Gathering</span>
|
||||
</button>
|
||||
<button
|
||||
class="mw-100"
|
||||
color="warn"
|
||||
mat-flat-button
|
||||
[disabled]="dataGatheringInProgress"
|
||||
(click)="onGatherMax()"
|
||||
>
|
||||
<ion-icon class="mr-1" name="warning-outline"></ion-icon>
|
||||
<span i18n>Gather All Data</span>
|
||||
</button>
|
||||
<div class="mb-2">
|
||||
<button
|
||||
class="mw-100"
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
(click)="onFlushCache()"
|
||||
>
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="close-circle-outline"
|
||||
></ion-icon>
|
||||
<span i18n>Reset Data Gathering</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<button
|
||||
class="mw-100"
|
||||
color="warn"
|
||||
mat-flat-button
|
||||
[disabled]="dataGatheringInProgress"
|
||||
(click)="onGatherMax()"
|
||||
>
|
||||
<ion-icon class="mr-1" name="warning-outline"></ion-icon>
|
||||
<span i18n>Gather All Data</span>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="mb-2 mr-2 mw-100"
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
(click)="onGatherProfileData()"
|
||||
>
|
||||
<ion-icon
|
||||
class="mr-1"
|
||||
name="cloud-download-outline"
|
||||
></ion-icon>
|
||||
<span i18n>Gather Profile Data</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,19 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HalloGhostfolioPageComponent,
|
||||
canActivate: [AuthGuard]
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class HalloGhostfolioPageRoutingModule {}
|
@ -0,0 +1,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-hallo-ghostfolio-page',
|
||||
templateUrl: './hallo-ghostfolio-page.html'
|
||||
})
|
||||
export class HalloGhostfolioPageComponent {}
|
@ -0,0 +1,194 @@
|
||||
<div class="blog container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<article>
|
||||
<div class="mb-4 text-center">
|
||||
<h1 class="mb-1" i18n>Hallo Ghostfolio 👋</h1>
|
||||
<div class="text-muted"><small>31.07.2021</small></div>
|
||||
</div>
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
In diesem Artikel möchte ich mein neues Open Source Projekt näher
|
||||
vorstellen: <a href="https://ghostfol.io">Ghostfolio</a>, eine
|
||||
web-basierte Software für das Management der persönlichen Finanzen.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Welches Problem löst Ghostfolio?</h2>
|
||||
<p>
|
||||
Aufgrund der steigenden Inflation und den Negativzinsen befasse ich
|
||||
mich seit einiger Zeit, wie ich mein Vermögen möglichst
|
||||
diversifiziert anlegen kann. Konkret verfolge ich eine
|
||||
<a [routerLink]="['/resources']">Buy and Hold Strategie</a> mit
|
||||
Investitionen in verschiedene Anlageklassen verteilt auf
|
||||
unterschiedliche Plattformen. Deshalb suchte ich nach einer App, die
|
||||
mein Portfolio ganzheitlich zusammenfasst. Bei meiner
|
||||
Internetrecherche und Suche in App Stores habe ich mehrere Lösungen
|
||||
ausprobiert, doch keine hat mich vollkommen überzeugt: Zu
|
||||
kompliziert, zu überladen, nicht optimiert für Smartphones oder zu
|
||||
wenig umfassend.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Die Vision</h2>
|
||||
<p>
|
||||
Besonders wichtig ist mir, dass ich in Echtzeit die Übersicht über
|
||||
mein gesamtes Vermögen erhalte. Bisher hatte ich nur einmal im Jahr,
|
||||
beim Abschluss der Steuererklärung, die aufbereiteten Zahlen zur
|
||||
Verfügung. Zum Gesamtbild gehören neben Cash auf dem Sparkonto auch
|
||||
länderspezifische Besonderheiten wie beispielsweise die freiwillige
|
||||
Altersvorsorge (Säule 3a) in der Schweiz.
|
||||
</p>
|
||||
<p>
|
||||
In der Zwischenzeit habe ich mit vielen Kollegen gesprochen, die
|
||||
schon länger investieren. Nicht wenige haben aus denselben
|
||||
Überlegungen über die Zeit ein komplexes Spreadsheet angelegt. Ich
|
||||
finde, dass dies im Jahr 2021 besser gehen muss.
|
||||
</p>
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<div class="col-md-10 offset-md-1">
|
||||
<blockquote class="blockquote m-0">
|
||||
<p class="mb-0">
|
||||
Ghostfolio zeigt das Gesamtbild des Vermögens, um
|
||||
bestmögliche Anlage-Entscheidungen zu treffen.
|
||||
</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Ghostfolio soll eine simple Wealth Management Software sein. Diese
|
||||
präsentiert jederzeit das aktuelle Vermögen und unterstützt bei
|
||||
zukünftigen Investments. Sei es beim Rebalancing des Portfolios in
|
||||
Anlageklassen (Aktien, Cryptocurrencies, ETFs, etc.) oder der
|
||||
Finanzierung einer Wohnung, Ghostfolio bietet eine solide,
|
||||
datengestützte Entscheidungshilfe.
|
||||
</p>
|
||||
<p>
|
||||
Ich lege grossen Wert auf Datenschutz. Als
|
||||
<a href="https://github.com/ghostfolio/ghostfolio"
|
||||
>Open Source Software</a
|
||||
>
|
||||
(OSS) kann Ghostfolio vollständig anonym genutzt werden, ohne die
|
||||
gierigen Blicke von Grossbanken oder Big Tech.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Von der Idee zur Umsetzung</h2>
|
||||
<p>
|
||||
Vor diesem Hintergrund habe ich das ambitionierte Projekt gestartet
|
||||
und in kleinen Schritten eine Software programmiert, die ich rasch
|
||||
selbst nutzen konnte. Als Stack habe ich mich für moderne
|
||||
Web-Technologien entschieden, die mich persönlich besonders
|
||||
interessieren beziehungsweise die ich gerne erlernen und vertiefen
|
||||
möchte. Dazu zählen <a href="https://www.docker.com">Docker</a>,
|
||||
<a href="https://nx.dev">Nx</a> für das Management des Monorepos,
|
||||
<a href="https://nestjs.com">NestJS</a> für das Backend und
|
||||
<a href="https://www.postgresql.org">PostgreSQL</a> als Datenbank.
|
||||
Der Code ist sowohl im Frontend als auch im Backend in
|
||||
<a href="https://www.typescriptlang.org">TypeScript</a>
|
||||
geschrieben.
|
||||
</p>
|
||||
<p>
|
||||
Da ich bei einigen Kalkulationen an meine Grenzen gestossen bin,
|
||||
habe ich mit verschiedenen Möglichkeiten auseinandergesetzt, um
|
||||
Unterstützung zu bekommen. In der Hoffnung, dass andere Leute
|
||||
ebenfalls von der Lösung profitieren und bei Interesse mit
|
||||
Verbesserungen beitragen können, habe ich den bestehenden Code als
|
||||
Open Source Software veröffentlicht. Schon nach kurzer Zeit haben
|
||||
<a
|
||||
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
|
||||
>andere Entwickler</a
|
||||
>
|
||||
mit tollen Erweiterungen an Ghostfolio mitgewirkt.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Wie kann ich das Projekt unterstützen?</h2>
|
||||
<p>
|
||||
Bist du ebenfalls besessen von einer maximal diversifizierten
|
||||
Anlagestrategie? Ich freue mich über alle, die Ghostfolio
|
||||
ausprobieren. Bist du überzeugt vom Potential der Software? Jede
|
||||
Unterstützung für Ghostfolio ist willkommen. Sei es mit einer
|
||||
<a href="https://ghostfol.io/pricing">Ghostfolio Premium</a>
|
||||
Subscription zur Finanzierung des Hostings, einem positiven Rating
|
||||
im
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=ch.dotsilver.ghostfolio.twa"
|
||||
>Google Play Store</a
|
||||
>, einem Sternchen auf
|
||||
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>,
|
||||
Feedback, Bug Reports, Feature Requests und natürlich Contributions!
|
||||
</p>
|
||||
<p>
|
||||
Du erreichst mich per E-Mail unter
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> oder auf Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
Ich freue mich, von dir zu hören.<br />
|
||||
Thomas von Ghostfolio
|
||||
</p>
|
||||
</section>
|
||||
<section class="my-5">
|
||||
<ul class="list-inline">
|
||||
<li class="h5">
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Aktie</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Altersvorsorge</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Anlage</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2">App</span>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Cryptocurrency</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2">ETF</span>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Feedback</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Fintech</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Ghostfolio</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Investition</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Open Source</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2">OSS</span>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Portfolio</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Software</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Strategie</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Trading</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>TypeScript</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Vermögen</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Wealth Management</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user