Feature/add balance to account (#193)

* Add balance attribute and calculate total balance

* Update changelog
This commit is contained in:
Thomas 2021-07-07 21:23:36 +02:00 committed by GitHub
parent db090229ce
commit 2c19d8c8e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 163 additions and 38 deletions

View File

@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the total value in the create or edit transaction dialog - 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 ### Changed

View File

@ -4,6 +4,7 @@ import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alph
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { 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 { 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 { 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 { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -20,6 +21,7 @@ import { AccountService } from './account.service';
AlphaVantageService, AlphaVantageService,
ConfigurationService, ConfigurationService,
DataProviderService, DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
ImpersonationService, ImpersonationService,
PrismaService, PrismaService,

View File

@ -1,12 +1,14 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common'; 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 { RedisCacheService } from '../redis-cache/redis-cache.service';
@Injectable() @Injectable()
export class AccountService { export class AccountService {
public constructor( public constructor(
private exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private prisma: PrismaService private prisma: PrismaService
) {} ) {}
@ -53,6 +55,24 @@ export class AccountService {
}); });
} }
public async calculateCashBalance(aUserId: string, aCurrency: Currency) {
let totalCashBalance = 0;
const accounts = await this.accounts({
where: { userId: aUserId }
});
accounts.forEach((account) => {
totalCashBalance += this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
);
});
return totalCashBalance;
}
public async createAccount( public async createAccount(
data: Prisma.AccountCreateInput, data: Prisma.AccountCreateInput,
aUserId: string aUserId: string

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { Currency, DataSource, Type } from '@prisma/client'; 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 { export class CreateOrderDto {
@IsString() @IsString()

View File

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

View File

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

View File

@ -1,3 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { Portfolio } from '@ghostfolio/api/models/portfolio'; import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
@ -30,9 +34,6 @@ import {
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import * as roundTo from 'round-to'; 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 { import {
HistoricalDataItem, HistoricalDataItem,
PortfolioPositionDetail PortfolioPositionDetail
@ -41,6 +42,7 @@ import {
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
public constructor( public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
@ -192,10 +194,15 @@ export class PortfolioService {
impersonationUserId || this.request.user.id impersonationUserId || this.request.user.id
); );
const cash = await this.accountService.calculateCashBalance(
impersonationUserId || this.request.user.id,
this.request.user.Settings.currency
);
const committedFunds = portfolio.getCommittedFunds(); const committedFunds = portfolio.getCommittedFunds();
const fees = portfolio.getFees(); const fees = portfolio.getFees();
return { return {
cash,
committedFunds, committedFunds,
fees, fees,
ordersCount: portfolio.getOrders().length, ordersCount: portfolio.getOrders().length,

View File

@ -110,7 +110,9 @@ describe('Portfolio', () => {
Account: [ Account: [
{ {
accountType: AccountType.SECURITIES, accountType: AccountType.SECURITIES,
balance: 0,
createdAt: new Date(), createdAt: new Date(),
currency: Currency.USD,
id: DEFAULT_ACCOUNT_ID, id: DEFAULT_ACCOUNT_ID,
isDefault: true, isDefault: true,
name: 'Default Account', name: 'Default Account',

View File

@ -26,6 +26,27 @@
</td> </td>
</ng-container> </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"> <ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th> <th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell> <td *matCellDef="let element" class="px-1 text-center" mat-cell>
@ -53,15 +74,6 @@
</td> </td>
</ng-container> </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 *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table> </table>

View File

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

View File

@ -1,4 +1,18 @@
<div class="container p-0"> <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="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Buy</div> <div class="d-flex flex-grow-1" i18n>Buy</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">

View File

@ -125,6 +125,8 @@ export class AccountsPageComponent implements OnInit {
public openUpdateAccountDialog({ public openUpdateAccountDialog({
accountType, accountType,
balance,
currency,
id, id,
name, name,
platformId platformId
@ -133,6 +135,8 @@ export class AccountsPageComponent implements OnInit {
data: { data: {
account: { account: {
accountType, accountType,
balance,
currency,
id, id,
name, name,
platformId platformId
@ -167,6 +171,8 @@ export class AccountsPageComponent implements OnInit {
data: { data: {
account: { account: {
accountType: AccountType.SECURITIES, accountType: AccountType.SECURITIES,
balance: 0,
currency: this.user?.settings?.baseCurrency,
name: null, name: null,
platformId: null platformId: null
} }

View File

@ -8,14 +8,37 @@
<input matInput name="name" required [(ngModel)]="data.account.name" /> <input matInput name="name" required [(ngModel)]="data.account.name" />
</mat-form-field> </mat-form-field>
</div> </div>
<div class="d-none"> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label> <mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.account.accountType"> <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-select>
</mat-form-field> </mat-form-field>
</div> </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> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Platform</mat-label> <mat-label i18n>Platform</mat-label>

View File

@ -1,4 +1,5 @@
export interface PortfolioOverview { export interface PortfolioOverview {
cash: number;
committedFunds: number; committedFunds: number;
fees: number; fees: number;
ordersCount: number; ordersCount: number;

View File

@ -0,0 +1,6 @@
-- AlterEnum
ALTER TYPE "AccountType" ADD VALUE 'CASH';
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "balance" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN "currency" "Currency" NOT NULL DEFAULT E'USD';

View File

@ -26,7 +26,9 @@ model Access {
model Account { model Account {
accountType AccountType @default(SECURITIES) accountType AccountType @default(SECURITIES)
balance Float @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
currency Currency @default(USD)
id String @default(uuid()) id String @default(uuid())
isDefault Boolean @default(false) isDefault Boolean @default(false)
name String? name String?
@ -158,6 +160,7 @@ model User {
} }
enum AccountType { enum AccountType {
CASH
SECURITIES SECURITIES
} }

View File

@ -87,6 +87,8 @@ async function main() {
create: [ create: [
{ {
accountType: AccountType.SECURITIES, accountType: AccountType.SECURITIES,
balance: 0,
currency: Currency.USD,
id: 'f4425b66-9ba9-4ac4-93d7-fdf9a145e8cb', id: 'f4425b66-9ba9-4ac4-93d7-fdf9a145e8cb',
isDefault: true, isDefault: true,
name: 'Default Account' name: 'Default Account'
@ -109,18 +111,24 @@ async function main() {
create: [ create: [
{ {
accountType: AccountType.SECURITIES, accountType: AccountType.SECURITIES,
balance: 0,
currency: Currency.USD,
id: 'd804de69-0429-42dc-b6ca-b308fd7dd926', id: 'd804de69-0429-42dc-b6ca-b308fd7dd926',
name: 'Coinbase Account', name: 'Coinbase Account',
platformId: platformCoinbase.id platformId: platformCoinbase.id
}, },
{ {
accountType: AccountType.SECURITIES, accountType: AccountType.SECURITIES,
balance: 0,
currency: Currency.EUR,
id: '65cfb79d-b6c7-4591-9d46-73426bc62094', id: '65cfb79d-b6c7-4591-9d46-73426bc62094',
name: 'DEGIRO Account', name: 'DEGIRO Account',
platformId: platformDegiro.id platformId: platformDegiro.id
}, },
{ {
accountType: AccountType.SECURITIES, accountType: AccountType.SECURITIES,
balance: 0,
currency: Currency.USD,
id: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c', id: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
isDefault: true, isDefault: true,
name: 'Interactive Brokers Account', name: 'Interactive Brokers Account',