Compare commits
13 Commits
Author | SHA1 | Date | |
---|---|---|---|
b08ecd1b18 | |||
92d321a001 | |||
ce2d8d519d | |||
f32bef071e | |||
4aa7365d9b | |||
367f25a975 | |||
9832334da1 | |||
e126f9ec54 | |||
09bbda3502 | |||
ee9a521813 | |||
169c151547 | |||
3a95ec0f81 | |||
ad00cd9d81 |
32
CHANGELOG.md
32
CHANGELOG.md
@ -5,6 +5,38 @@ 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.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
|
||||
|
12
README.md
12
README.md
@ -8,8 +8,8 @@
|
||||
</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="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>
|
||||
@ -84,7 +84,7 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
||||
|
||||
## 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 +101,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)
|
||||
|
@ -27,6 +27,7 @@ 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';
|
||||
|
||||
@ -59,6 +60,7 @@ import { UserModule } from './user/user.module';
|
||||
rootPath: join(__dirname, '..', 'client'),
|
||||
exclude: ['/api*']
|
||||
}),
|
||||
SubscriptionModule,
|
||||
SymbolModule,
|
||||
UserModule
|
||||
],
|
||||
|
@ -1,6 +1,7 @@
|
||||
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';
|
||||
@ -44,37 +45,8 @@ export class InfoService {
|
||||
currencies: Object.values(Currency),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
statistics: await this.getStatistics()
|
||||
};
|
||||
}
|
||||
|
||||
private getDemoAuthToken() {
|
||||
return this.jwtService.sign({
|
||||
id: InfoService.DEMO_USER_ID
|
||||
});
|
||||
}
|
||||
|
||||
private async getLastDataGathering() {
|
||||
const lastDataGathering = await this.prisma.property.findUnique({
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
});
|
||||
|
||||
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
|
||||
statistics: await this.getStatistics(),
|
||||
subscriptions: await this.getSubscriptions()
|
||||
};
|
||||
}
|
||||
|
||||
@ -124,4 +96,50 @@ export class InfoService {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getDemoAuthToken() {
|
||||
return this.jwtService.sign({
|
||||
id: InfoService.DEMO_USER_ID
|
||||
});
|
||||
}
|
||||
|
||||
private async getLastDataGathering() {
|
||||
const lastDataGathering = await this.prisma.property.findUnique({
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
});
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
@ -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,16 @@ export class OrderService {
|
||||
): Promise<Order> {
|
||||
this.redisCacheService.remove(`${aUserId}.portfolio`);
|
||||
|
||||
// Gather symbol data of order in the background
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: data.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: data.symbol
|
||||
}
|
||||
]);
|
||||
if (!isAfter(data.date as Date, endOfToday())) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: data.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: data.symbol
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
await this.cacheService.flush(aUserId);
|
||||
|
||||
|
@ -14,6 +14,7 @@ import { REQUEST } from '@nestjs/core';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
add,
|
||||
endOfToday,
|
||||
format,
|
||||
getDate,
|
||||
getMonth,
|
||||
@ -52,7 +53,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,9 +64,8 @@ export class PortfolioService {
|
||||
const {
|
||||
orders,
|
||||
portfolioItems
|
||||
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } = JSON.parse(
|
||||
stringifiedPortfolio
|
||||
);
|
||||
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } =
|
||||
JSON.parse(stringifiedPortfolio);
|
||||
|
||||
portfolio = new Portfolio(
|
||||
this.dataProviderService,
|
||||
@ -104,15 +104,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 +133,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 +155,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,10 +182,11 @@ 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
|
||||
@ -195,10 +208,11 @@ 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
|
||||
@ -318,7 +332,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({
|
||||
|
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,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;
|
||||
}
|
||||
|
@ -275,7 +275,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 +311,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 +347,7 @@ describe('Portfolio', () => {
|
||||
symbol: 'ETHUSD',
|
||||
type: 'Cryptocurrency'
|
||||
}
|
||||
});
|
||||
});*/
|
||||
|
||||
expect(portfolio.getFees()).toEqual(0);
|
||||
|
||||
|
@ -73,7 +73,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] = {
|
||||
@ -105,14 +105,49 @@ 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) => {
|
||||
const portfolioItem = this.portfolioItems.find((item) => {
|
||||
return item.date === order.getDate();
|
||||
});
|
||||
|
||||
if (portfolioItem) {
|
||||
portfolioItem.investment += this.exchangeRateDataService.toCurrency(
|
||||
order.getTotal(),
|
||||
order.getCurrency(),
|
||||
this.user.Settings.currency
|
||||
);
|
||||
} else {
|
||||
investment += this.exchangeRateDataService.toCurrency(
|
||||
order.getTotal(),
|
||||
order.getCurrency(),
|
||||
this.user.Settings.currency
|
||||
);
|
||||
|
||||
this.portfolioItems.push({
|
||||
investment,
|
||||
date: order.getDate(),
|
||||
grossPerformancePercent: 0,
|
||||
positions: {},
|
||||
value: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public createFromData({
|
||||
orders,
|
||||
portfolioItems,
|
||||
@ -178,6 +213,8 @@ export class Portfolio implements PortfolioInterface {
|
||||
if (filteredPortfolio) {
|
||||
return [cloneDeep(filteredPortfolio)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return cloneDeep(this.portfolioItems);
|
||||
@ -239,12 +276,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,
|
||||
@ -282,7 +317,7 @@ export class Portfolio implements PortfolioInterface {
|
||||
let now = portfolioItemsNow.positions[symbol].marketPrice;
|
||||
|
||||
// 1d
|
||||
let before = portfolioItemsBefore.positions[symbol].marketPrice;
|
||||
let before = portfolioItemsBefore?.positions[symbol].marketPrice;
|
||||
|
||||
if (aDateRange === 'ytd') {
|
||||
before =
|
||||
@ -299,7 +334,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
|
||||
@ -365,7 +400,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());
|
||||
}
|
||||
|
||||
@ -492,9 +531,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
|
||||
@ -503,7 +544,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(),
|
||||
@ -516,7 +559,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(),
|
||||
@ -686,10 +731,10 @@ export class Portfolio implements PortfolioInterface {
|
||||
|
||||
this.portfolioItems.push(
|
||||
cloneDeep({
|
||||
positions,
|
||||
date: yesterday.toISOString(),
|
||||
grossPerformancePercent: 0,
|
||||
investment: 0,
|
||||
positions: positions,
|
||||
value: 0
|
||||
})
|
||||
);
|
||||
@ -746,8 +791,6 @@ export class Portfolio implements PortfolioInterface {
|
||||
}
|
||||
|
||||
private updatePortfolioItems() {
|
||||
// console.time('update-portfolio-items');
|
||||
|
||||
let currentDate = new Date();
|
||||
|
||||
const year = getYear(currentDate);
|
||||
@ -771,107 +814,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');
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ export class ConfigurationService {
|
||||
REDIS_HOST: str({ default: 'localhost' }),
|
||||
REDIS_PORT: port({ default: 6379 }),
|
||||
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
||||
STRIPE_SECRET_KEY: str({ default: '' }),
|
||||
WEB_AUTH_RP_ID: host({ default: 'localhost' })
|
||||
});
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
differenceInHours,
|
||||
endOfToday,
|
||||
format,
|
||||
getDate,
|
||||
getMonth,
|
||||
@ -187,7 +188,8 @@ export class DataGatheringService {
|
||||
public async getCustomSymbolsToGather(
|
||||
startDate?: Date
|
||||
): Promise<IDataGatheringItem[]> {
|
||||
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations();
|
||||
const scraperConfigurations =
|
||||
await this.ghostfolioScraperApi.getScraperConfigurations();
|
||||
|
||||
return scraperConfigurations.map((scraperConfiguration) => {
|
||||
return {
|
||||
@ -224,7 +226,12 @@ export class DataGatheringService {
|
||||
const distinctOrders = await this.prisma.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: { dataSource: true, symbol: true }
|
||||
select: { dataSource: true, symbol: true },
|
||||
where: {
|
||||
date: {
|
||||
lt: endOfToday() // no draft
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
|
||||
@ -280,7 +287,12 @@ export class DataGatheringService {
|
||||
const distinctOrders = await this.prisma.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { dataSource: true, date: true, symbol: true }
|
||||
select: { dataSource: true, date: true, symbol: true },
|
||||
where: {
|
||||
date: {
|
||||
lt: endOfToday() // no draft
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
|
@ -19,5 +19,6 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
REDIS_HOST: string;
|
||||
REDIS_PORT: number;
|
||||
ROOT_URL: string;
|
||||
STRIPE_SECRET_KEY: string;
|
||||
WEB_AUTH_RP_ID: string;
|
||||
}
|
||||
|
@ -15,7 +15,9 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MaterialCssVarsModule } from 'angular-material-css-vars';
|
||||
import { MarkdownModule } from 'ngx-markdown';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { NgxStripeModule } from 'ngx-stripe';
|
||||
|
||||
import { environment } from '../environments/environment';
|
||||
import { CustomDateAdapter } from './adapter/custom-date-adapter';
|
||||
import { DateFormats } from './adapter/date-formats';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
@ -43,7 +45,8 @@ import { LanguageService } from './core/language.service';
|
||||
}),
|
||||
MatNativeDateModule,
|
||||
MatSnackBarModule,
|
||||
NgxSkeletonLoaderModule
|
||||
NgxSkeletonLoaderModule,
|
||||
NgxStripeModule.forRoot(environment.stripePublicKey)
|
||||
],
|
||||
providers: [
|
||||
authInterceptorProviders,
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
@ -72,9 +73,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 +114,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
|
||||
|
@ -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
|
||||
|
@ -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,7 +39,8 @@ const SEARCH_STRING_SEPARATOR = ',';
|
||||
styleUrls: ['./transactions-table.component.scss']
|
||||
})
|
||||
export class TransactionsTableComponent
|
||||
implements OnChanges, OnDestroy, OnInit {
|
||||
implements OnChanges, OnDestroy, OnInit
|
||||
{
|
||||
@Input() baseCurrency: string;
|
||||
@Input() deviceType: string;
|
||||
@Input() locale: string;
|
||||
@ -54,11 +55,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;
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
<p>
|
||||
<strong>Ghostfolio</strong> is open source software which empowers
|
||||
@ -17,21 +17,13 @@
|
||||
</p>
|
||||
<p>
|
||||
If you encounter a bug or would like to suggest an improvement or a
|
||||
new feature, please open an issue at
|
||||
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>, tweet
|
||||
to <a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or
|
||||
send an e-mail to
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
|
||||
new feature, please tweet to
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>, send an
|
||||
e-mail to <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or open
|
||||
an issue at
|
||||
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
|
||||
</p>
|
||||
<p class="text-center">
|
||||
<a
|
||||
class="mx-2"
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
mat-icon-button
|
||||
title="Find Ghostfolio on GitHub"
|
||||
>
|
||||
<ion-icon name="logo-github" size="large"></ion-icon>
|
||||
</a>
|
||||
<a
|
||||
class="mx-2"
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
@ -48,6 +40,14 @@
|
||||
>
|
||||
<ion-icon name="mail" size="large"></ion-icon>
|
||||
</a>
|
||||
<a
|
||||
class="mx-2"
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
mat-icon-button
|
||||
title="Find Ghostfolio on GitHub"
|
||||
>
|
||||
<ion-icon name="logo-github" size="large"></ion-icon>
|
||||
</a>
|
||||
</p>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div
|
||||
@ -63,10 +63,10 @@
|
||||
<div *ngIf="hasPermissionForStatistics" class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="mb-3 text-center" i18n>Ghostfolio in Numbers</h3>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0" [hidden]="!statistics?.activeUsers1d">
|
||||
{{ statistics?.activeUsers1d ?? '-' }}
|
||||
</h3>
|
||||
@ -74,15 +74,15 @@
|
||||
Active Users <small class="text-muted">(Last 24 hours)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0" [hidden]="!statistics?.activeUsers30d">
|
||||
{{ statistics?.activeUsers30d ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 m-b0">
|
||||
<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">
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0" [hidden]="!statistics?.gitHubStargazers">
|
||||
{{ statistics?.gitHubStargazers ?? '-' }}
|
||||
</h3>
|
||||
@ -97,7 +97,7 @@
|
||||
<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>
|
||||
@ -108,7 +108,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>
|
||||
|
@ -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 { baseCurrency, DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||
import { Access, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { StripeService } from 'ngx-stripe';
|
||||
import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, takeUntil } from 'rxjs/operators';
|
||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-account-page',
|
||||
@ -29,10 +30,16 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
signInWithFingerprintElement: MatSlideToggle;
|
||||
|
||||
public accesses: Access[];
|
||||
public baseCurrency: Currency;
|
||||
public baseCurrency = baseCurrency;
|
||||
public coupon: number;
|
||||
public couponId: string;
|
||||
public currencies: Currency[] = [];
|
||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||
public hasPermissionForSubscription;
|
||||
public hasPermissionToUpdateViewMode: boolean;
|
||||
public hasPermissionToUpdateUserSettings: boolean;
|
||||
public price: number;
|
||||
public priceId: string;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -43,14 +50,27 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private stripeService: StripeService,
|
||||
private userService: UserService,
|
||||
public webAuthnService: WebAuthnService
|
||||
) {
|
||||
this.dataService
|
||||
.fetchInfo()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ currencies }) => {
|
||||
.subscribe(({ currencies, globalPermissions, subscriptions }) => {
|
||||
this.coupon = subscriptions?.[0]?.coupon;
|
||||
this.couponId = subscriptions?.[0]?.couponId;
|
||||
this.currencies = currencies;
|
||||
|
||||
this.hasPermissionForSubscription = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableSubscription
|
||||
);
|
||||
|
||||
this.price = subscriptions?.[0]?.price;
|
||||
this.priceId = subscriptions?.[0]?.priceId;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
@ -64,6 +84,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
permissions.updateUserSettings
|
||||
);
|
||||
|
||||
this.hasPermissionToUpdateViewMode = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.updateViewMode
|
||||
);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
@ -99,6 +124,23 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onCheckout() {
|
||||
this.dataService
|
||||
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
|
||||
.pipe(
|
||||
switchMap(({ sessionId }: { sessionId: string }) => {
|
||||
return this.stripeService.redirectToCheckout({
|
||||
sessionId
|
||||
});
|
||||
})
|
||||
)
|
||||
.subscribe((result) => {
|
||||
if (result.error) {
|
||||
alert(result.error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public onSignInWithFingerprintChange(aEvent: MatSlideToggleChange) {
|
||||
if (aEvent.checked) {
|
||||
this.registerDevice();
|
||||
|
@ -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">
|
||||
@ -25,10 +22,26 @@
|
||||
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>
|
||||
@ -51,18 +64,25 @@
|
||||
>
|
||||
</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>
|
||||
<div class="align-items-center d-flex overflow-hidden">
|
||||
<mat-form-field appearance="outline" class="flex-grow-1">
|
||||
<mat-label i18n>View Mode</mat-label>
|
||||
<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>
|
||||
<ion-icon
|
||||
*ngIf="!hasPermissionToUpdateViewMode"
|
||||
class="h5 mb-0 mx-3 text-muted"
|
||||
name="diamond-outline"
|
||||
></ion-icon>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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,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">
|
||||
|
@ -24,4 +24,4 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class CreateOrUpdateAccountDialogModule {}
|
||||
export class GfCreateOrUpdateAccountDialogModule {}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ChangeDetectorRef, Component, 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';
|
||||
@ -12,7 +13,9 @@ import { takeUntil } from 'rxjs/operators';
|
||||
})
|
||||
export class PricingPageComponent implements OnInit {
|
||||
public baseCurrency = baseCurrency;
|
||||
public coupon: number;
|
||||
public isLoggedIn: boolean;
|
||||
public price: number;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -22,8 +25,19 @@ export class PricingPageComponent implements OnInit {
|
||||
*/
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private userService: UserService
|
||||
) {}
|
||||
) {
|
||||
this.dataService
|
||||
.fetchInfo()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ subscriptions }) => {
|
||||
this.coupon = this.price = subscriptions?.[0]?.coupon;
|
||||
this.price = subscriptions?.[0]?.price;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the controller
|
||||
|
@ -176,11 +176,17 @@
|
||||
</ul>
|
||||
</div>
|
||||
<p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p>
|
||||
<p class="h5 text-right">
|
||||
<p class="h5 text-right" [hidden]="!price">
|
||||
<span class="font-weight-normal"
|
||||
>{{ user?.settings.baseCurrency || baseCurrency }}
|
||||
<strong>0.00</strong>
|
||||
<del class="ml-1 text-muted">3.99</del> / Month</span
|
||||
>{{ baseCurrency }}
|
||||
<ng-container *ngIf="coupon"
|
||||
><strong>{{ price - coupon | number : '1.2-2' }} </strong>
|
||||
<del>{{ price }}</del>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!coupon"
|
||||
><strong>{{ price }}</strong></ng-container
|
||||
>
|
||||
<span i18n> per year</span></span
|
||||
>
|
||||
</p>
|
||||
</mat-card>
|
||||
@ -188,6 +194,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="user?.subscription?.type === 'Basic'" class="row">
|
||||
<div class="col mt-3 text-center">
|
||||
<a color="primary" i18n mat-flat-button [routerLink]="['/account']">
|
||||
Upgrade Plan
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="!user" class="row">
|
||||
<div class="col mt-3 text-center">
|
||||
<a color="primary" i18n mat-flat-button [routerLink]="['/register']">
|
||||
|
@ -192,7 +192,7 @@
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="investment-chart row">
|
||||
<div class="col-lg">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header>
|
||||
|
@ -1,4 +1,16 @@
|
||||
:host {
|
||||
.investment-chart {
|
||||
.mat-card {
|
||||
.mat-card-content {
|
||||
aspect-ratio: 16 / 9;
|
||||
|
||||
gf-investment-chart {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.proportion-charts {
|
||||
.mat-card {
|
||||
.mat-card-content {
|
||||
|
@ -43,6 +43,19 @@ export class DataService {
|
||||
private settingsStorageService: SettingsStorageService
|
||||
) {}
|
||||
|
||||
public createCheckoutSession({
|
||||
couponId,
|
||||
priceId
|
||||
}: {
|
||||
couponId?: string;
|
||||
priceId: string;
|
||||
}) {
|
||||
return this.http.post('/api/subscription/stripe/checkout-session', {
|
||||
couponId,
|
||||
priceId
|
||||
});
|
||||
}
|
||||
|
||||
public fetchAccounts() {
|
||||
return this.http.get<AccountModel[]>('/api/account');
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
export const environment = {
|
||||
lastPublish: '{BUILD_TIMESTAMP}',
|
||||
production: true,
|
||||
stripePublicKey: '{STRIPE_PUBLIC_KEY}',
|
||||
version: `v${require('../../../../package.json').version}`
|
||||
};
|
||||
|
@ -5,6 +5,7 @@
|
||||
export const environment = {
|
||||
lastPublish: null,
|
||||
production: false,
|
||||
stripePublicKey: '',
|
||||
version: 'dev'
|
||||
};
|
||||
|
||||
|
2
apps/client/src/styles/bootstrap.scss
vendored
2
apps/client/src/styles/bootstrap.scss
vendored
@ -27,7 +27,7 @@
|
||||
// @import '~bootstrap/scss/card';
|
||||
// @import '~bootstrap/scss/breadcrumb';
|
||||
// @import '~bootstrap/scss/pagination';
|
||||
// @import '~bootstrap/scss/badge';
|
||||
@import '~bootstrap/scss/badge';
|
||||
// @import '~bootstrap/scss/jumbotron';
|
||||
// @import '~bootstrap/scss/alert';
|
||||
// @import '~bootstrap/scss/progress';
|
||||
|
@ -2,7 +2,7 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac
|
||||
import { Currency } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export const baseCurrency = Currency.CHF;
|
||||
export const baseCurrency = Currency.USD;
|
||||
|
||||
export const benchmarks: Partial<IDataGatheringItem>[] = [
|
||||
{ dataSource: DataSource.YAHOO, symbol: 'VOO' }
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { Statistics } from './statistics.interface';
|
||||
import { Subscription } from './subscription.interface';
|
||||
|
||||
export interface InfoItem {
|
||||
currencies: Currency[];
|
||||
@ -13,4 +14,5 @@ export interface InfoItem {
|
||||
};
|
||||
platforms: { id: string; name: string }[];
|
||||
statistics: Statistics;
|
||||
subscriptions: Subscription[];
|
||||
}
|
||||
|
6
libs/common/src/lib/interfaces/subscription.interface.ts
Normal file
6
libs/common/src/lib/interfaces/subscription.interface.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface Subscription {
|
||||
coupon?: number;
|
||||
couponId?: string;
|
||||
price: number;
|
||||
priceId: string;
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { Currency, ViewMode } from '@prisma/client';
|
||||
|
||||
export interface UserSettings {
|
||||
baseCurrency: Currency;
|
||||
baseCurrency?: Currency;
|
||||
locale: string;
|
||||
viewMode: ViewMode;
|
||||
viewMode?: ViewMode;
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import { Account, Settings, User } from '@prisma/client';
|
||||
|
||||
export type UserWithSettings = User & {
|
||||
Account: Account[];
|
||||
permissions?: string[];
|
||||
Settings: Settings;
|
||||
subscription?: {
|
||||
expiresAt?: Date;
|
||||
|
@ -21,7 +21,8 @@ export const permissions = {
|
||||
updateAccount: 'updateAccount',
|
||||
updateAuthDevice: 'updateAuthDevice',
|
||||
updateOrder: 'updateOrder',
|
||||
updateUserSettings: 'updateUserSettings'
|
||||
updateUserSettings: 'updateUserSettings',
|
||||
updateViewMode: 'updateViewMode'
|
||||
};
|
||||
|
||||
export function hasPermission(
|
||||
@ -46,7 +47,8 @@ export function getPermissions(aRole: Role): string[] {
|
||||
permissions.updateAccount,
|
||||
permissions.updateAuthDevice,
|
||||
permissions.updateOrder,
|
||||
permissions.updateUserSettings
|
||||
permissions.updateUserSettings,
|
||||
permissions.updateViewMode
|
||||
];
|
||||
|
||||
case 'DEMO':
|
||||
@ -62,7 +64,8 @@ export function getPermissions(aRole: Role): string[] {
|
||||
permissions.updateAccount,
|
||||
permissions.updateAuthDevice,
|
||||
permissions.updateOrder,
|
||||
permissions.updateUserSettings
|
||||
permissions.updateUserSettings,
|
||||
permissions.updateViewMode
|
||||
];
|
||||
|
||||
default:
|
||||
|
10
package.json
10
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "1.19.0",
|
||||
"version": "1.23.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -13,7 +13,7 @@
|
||||
"affected:lint": "nx affected:lint",
|
||||
"affected:test": "nx affected:test",
|
||||
"angular": "node --max_old_space_size=32768 ./node_modules/@angular/cli/bin/ng",
|
||||
"build:all": "ng build --prod api && ng build --prod client && yarn replace-placeholders-in-build",
|
||||
"build:all": "ng build --configuration production api && ng build --configuration production client && yarn replace-placeholders-in-build",
|
||||
"clean": "rimraf dist",
|
||||
"database:format-schema": "prisma format",
|
||||
"database:generate-typings": "prisma generate",
|
||||
@ -69,7 +69,7 @@
|
||||
"@simplewebauthn/browser": "3.0.0",
|
||||
"@simplewebauthn/server": "3.0.0",
|
||||
"@simplewebauthn/typescript-types": "3.0.0",
|
||||
"@types/lodash": "4.14.168",
|
||||
"@stripe/stripe-js": "1.15.0",
|
||||
"alphavantage": "2.2.0",
|
||||
"angular-material-css-vars": "1.2.0",
|
||||
"bent": "7.3.12",
|
||||
@ -92,6 +92,7 @@
|
||||
"ngx-device-detector": "2.1.1",
|
||||
"ngx-markdown": "12.0.1",
|
||||
"ngx-skeleton-loader": "2.9.1",
|
||||
"ngx-stripe": "12.0.2",
|
||||
"passport": "0.4.1",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.0",
|
||||
@ -99,6 +100,7 @@
|
||||
"reflect-metadata": "0.1.13",
|
||||
"round-to": "5.0.0",
|
||||
"rxjs": "6.6.7",
|
||||
"stripe": "8.156.0",
|
||||
"svgmap": "2.1.1",
|
||||
"uuid": "8.3.2",
|
||||
"yahoo-finance": "0.3.6",
|
||||
@ -123,12 +125,14 @@
|
||||
"@nrwl/workspace": "12.3.6",
|
||||
"@types/cache-manager": "3.4.0",
|
||||
"@types/jest": "26.0.20",
|
||||
"@types/lodash": "4.14.168",
|
||||
"@types/node": "14.14.33",
|
||||
"@types/passport-google-oauth20": "2.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "4.27.0",
|
||||
"@typescript-eslint/parser": "4.27.0",
|
||||
"codelyzer": "6.0.1",
|
||||
"cypress": "6.2.1",
|
||||
"dotenv": "8.2.0",
|
||||
"eslint": "7.28.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-import": "2.23.4",
|
||||
|
@ -1,4 +1,11 @@
|
||||
const dotenv = require('dotenv');
|
||||
const path = require('path');
|
||||
const replace = require('replace-in-file');
|
||||
|
||||
dotenv.config({
|
||||
path: path.resolve(__dirname, '.env')
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const buildTimestamp = `${formatWithTwoDigits(
|
||||
now.getDate()
|
||||
@ -7,17 +14,24 @@ const buildTimestamp = `${formatWithTwoDigits(
|
||||
)}.${now.getFullYear()} ${formatWithTwoDigits(
|
||||
now.getHours()
|
||||
)}:${formatWithTwoDigits(now.getMinutes())}`;
|
||||
const options = {
|
||||
files: './dist/apps/client/main.*.js',
|
||||
from: /{BUILD_TIMESTAMP}/g,
|
||||
to: buildTimestamp,
|
||||
allowEmptyPaths: false
|
||||
};
|
||||
|
||||
try {
|
||||
const changedFiles = replace.sync(options);
|
||||
let changedFiles = replace.sync({
|
||||
files: './dist/apps/client/main.*.js',
|
||||
from: /{BUILD_TIMESTAMP}/g,
|
||||
to: buildTimestamp,
|
||||
allowEmptyPaths: false
|
||||
});
|
||||
console.log('Build version set: ' + buildTimestamp);
|
||||
console.log(changedFiles);
|
||||
|
||||
changedFiles = replace.sync({
|
||||
files: './dist/apps/client/main.*.js',
|
||||
from: /{STRIPE_PUBLIC_KEY}/g,
|
||||
to: process.env.STRIPE_PUBLIC_KEY ?? '',
|
||||
allowEmptyPaths: false
|
||||
});
|
||||
console.log(changedFiles);
|
||||
} catch (error) {
|
||||
console.error('Error occurred:', error);
|
||||
}
|
||||
|
41
yarn.lock
41
yarn.lock
@ -2454,6 +2454,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@stencil/core/-/core-2.5.2.tgz#ad00d495ee37bbed4044524d59c7f22de15ab4a7"
|
||||
integrity sha512-bgjPXkSzzg1WnTgVUm6m5ZzpKt602WmA/QljODAW1xVN40OHJdbGblzF/F6MFzqv2c5Cy30CB41arc8qADIdcQ==
|
||||
|
||||
"@stripe/stripe-js@1.15.0":
|
||||
version "1.15.0"
|
||||
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.15.0.tgz#86178cfbe66151910b09b03595e60048ab4c698e"
|
||||
integrity sha512-KQsNPc+uVQkc8dewwz1A6uHOWeU2cWoZyNIbsx5mtmperr5TPxw4u8M20WOa22n6zmIOh/zLdzEe8DYK/0IjBw==
|
||||
|
||||
"@tootallnate/once@1":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
|
||||
@ -2656,6 +2661,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.33.tgz#9e4f8c64345522e4e8ce77b334a8aaa64e2b6c78"
|
||||
integrity sha512-oJqcTrgPUF29oUP8AsUqbXGJNuPutsetaa9kTQAQce5Lx5dTYWV02ScBiT/k1BX/Z7pKeqedmvp39Wu4zR7N7g==
|
||||
|
||||
"@types/node@>=8.1.0":
|
||||
version "15.12.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-15.12.3.tgz#2817bf5f25bc82f56579018c53f7d41b1830b1af"
|
||||
integrity sha512-SNt65CPCXvGNDZ3bvk1TQ0Qxoe3y1RKH88+wZ2Uf05dduBCqqFQ76ADP9pbT+Cpvj60SkRppMCh2Zo8tDixqjQ==
|
||||
|
||||
"@types/normalize-package-data@^2.4.0":
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
|
||||
@ -9623,6 +9633,13 @@ ngx-skeleton-loader@2.9.1:
|
||||
perf-marks "^1.13.4"
|
||||
tslib "^1.10.0"
|
||||
|
||||
ngx-stripe@12.0.2:
|
||||
version "12.0.2"
|
||||
resolved "https://registry.yarnpkg.com/ngx-stripe/-/ngx-stripe-12.0.2.tgz#b250acc2a08dc96dac035fc0a67b4a8cbeca3efb"
|
||||
integrity sha512-/arfIi996yv3EpzqjYsb20TUdQ9t+GVMNVIx1mdsiWcpiNjL36tO3lG45T0hyiBJNAds87Ag40Fm8PfsuHFCUw==
|
||||
dependencies:
|
||||
tslib "^2.1.0"
|
||||
|
||||
nice-try@^1.0.4:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
|
||||
@ -11329,6 +11346,13 @@ qs@6.7.0:
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
|
||||
integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
|
||||
|
||||
qs@^6.6.0:
|
||||
version "6.10.1"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a"
|
||||
integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==
|
||||
dependencies:
|
||||
side-channel "^1.0.4"
|
||||
|
||||
qs@~6.5.2:
|
||||
version "6.5.2"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
|
||||
@ -12185,6 +12209,15 @@ shellwords@^0.1.1:
|
||||
resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b"
|
||||
integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==
|
||||
|
||||
side-channel@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
|
||||
integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
|
||||
dependencies:
|
||||
call-bind "^1.0.0"
|
||||
get-intrinsic "^1.0.2"
|
||||
object-inspect "^1.9.0"
|
||||
|
||||
signal-exit@^3.0.0, signal-exit@^3.0.2:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
|
||||
@ -12692,6 +12725,14 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
|
||||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
|
||||
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
|
||||
|
||||
stripe@8.156.0:
|
||||
version "8.156.0"
|
||||
resolved "https://registry.yarnpkg.com/stripe/-/stripe-8.156.0.tgz#040de551df88d71ef670a8c8d4df114c3fa6eb4b"
|
||||
integrity sha512-q+bixlhaxnSI/Htk/iB1i5LhuZ557hL0pFgECBxQNhso1elxIsOsPOIXEuo3tSLJEb8CJSB7t/+Fyq6KP69tAQ==
|
||||
dependencies:
|
||||
"@types/node" ">=8.1.0"
|
||||
qs "^6.6.0"
|
||||
|
||||
style-loader@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-2.0.0.tgz#9669602fd4690740eaaec137799a03addbbc393c"
|
||||
|
Reference in New Issue
Block a user