From 377ba75e4c70c35c625ef48d0ea651668f9b844d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 2 Dec 2023 17:17:25 +0100 Subject: [PATCH] Add support to delete a cash balance (#2707) --- .../account-balance.controller.ts | 51 +++++++++ .../account-balance/account-balance.module.ts | 5 +- .../account-balance.service.ts | 19 ++++ .../api/src/app/account/account.controller.ts | 2 +- apps/api/src/app/account/account.module.ts | 2 +- apps/api/src/app/account/account.service.ts | 2 +- apps/api/src/app/order/order.module.ts | 2 +- .../api/src/app/portfolio/portfolio.module.ts | 2 +- .../src/app/portfolio/portfolio.service.ts | 2 +- .../account-detail-dialog.component.ts | 105 ++++++++++++------ .../account-detail-dialog.html | 3 + apps/client/src/app/services/data.service.ts | 4 + libs/common/src/lib/permissions.ts | 3 + .../account-balances.component.html | 23 ++++ .../account-balances.component.ts | 55 ++++----- .../account-balances.module.ts | 11 +- 16 files changed, 226 insertions(+), 65 deletions(-) create mode 100644 apps/api/src/app/account-balance/account-balance.controller.ts rename apps/api/src/{services => app}/account-balance/account-balance.module.ts (68%) rename apps/api/src/{services => app}/account-balance/account-balance.service.ts (80%) diff --git a/apps/api/src/app/account-balance/account-balance.controller.ts b/apps/api/src/app/account-balance/account-balance.controller.ts new file mode 100644 index 000000000..f1538d7a5 --- /dev/null +++ b/apps/api/src/app/account-balance/account-balance.controller.ts @@ -0,0 +1,51 @@ +import type { RequestWithUser } from '@ghostfolio/common/types'; +import { + Controller, + Delete, + HttpException, + Inject, + Param, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AccountBalanceService } from './account-balance.service'; +import { AuthGuard } from '@nestjs/passport'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { AccountBalance } from '@prisma/client'; + +@Controller('account-balance') +export class AccountBalanceController { + public constructor( + private readonly accountBalanceService: AccountBalanceService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Delete(':id') + @UseGuards(AuthGuard('jwt')) + public async deleteAccountBalance( + @Param('id') id: string + ): Promise { + const accountBalance = await this.accountBalanceService.accountBalance({ + id + }); + + if ( + !hasPermission( + this.request.user.permissions, + permissions.deleteAccountBalance + ) || + !accountBalance || + accountBalance.userId !== this.request.user.id + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.accountBalanceService.deleteAccountBalance({ + id + }); + } +} diff --git a/apps/api/src/services/account-balance/account-balance.module.ts b/apps/api/src/app/account-balance/account-balance.module.ts similarity index 68% rename from apps/api/src/services/account-balance/account-balance.module.ts rename to apps/api/src/app/account-balance/account-balance.module.ts index c85727f8c..d78d9792e 100644 --- a/apps/api/src/services/account-balance/account-balance.module.ts +++ b/apps/api/src/app/account-balance/account-balance.module.ts @@ -1,9 +1,12 @@ -import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; +import { AccountBalanceController } from './account-balance.controller'; +import { AccountBalanceService } from './account-balance.service'; + @Module({ + controllers: [AccountBalanceController], exports: [AccountBalanceService], imports: [ExchangeRateDataModule, PrismaModule], providers: [AccountBalanceService] diff --git a/apps/api/src/services/account-balance/account-balance.service.ts b/apps/api/src/app/account-balance/account-balance.service.ts similarity index 80% rename from apps/api/src/services/account-balance/account-balance.service.ts rename to apps/api/src/app/account-balance/account-balance.service.ts index e1d002428..0845eda5a 100644 --- a/apps/api/src/services/account-balance/account-balance.service.ts +++ b/apps/api/src/app/account-balance/account-balance.service.ts @@ -12,6 +12,17 @@ export class AccountBalanceService { private readonly prismaService: PrismaService ) {} + public async accountBalance( + accountBalanceWhereInput: Prisma.AccountBalanceWhereInput + ): Promise { + return this.prismaService.accountBalance.findFirst({ + include: { + Account: true + }, + where: accountBalanceWhereInput + }); + } + public async createAccountBalance( data: Prisma.AccountBalanceCreateInput ): Promise { @@ -20,6 +31,14 @@ export class AccountBalanceService { }); } + public async deleteAccountBalance( + where: Prisma.AccountBalanceWhereUniqueInput + ): Promise { + return this.prismaService.accountBalance.delete({ + where + }); + } + public async getAccountBalances({ filters, user, diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 3eeb7117c..772a66e4c 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -1,6 +1,6 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; -import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { diff --git a/apps/api/src/app/account/account.module.ts b/apps/api/src/app/account/account.module.ts index 26ace47c2..a8fb7e848 100644 --- a/apps/api/src/app/account/account.module.ts +++ b/apps/api/src/app/account/account.module.ts @@ -1,7 +1,7 @@ +import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; -import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index bc6abcc7a..366d0b1a0 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -1,4 +1,4 @@ -import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { Filter } from '@ghostfolio/common/interfaces'; diff --git a/apps/api/src/app/order/order.module.ts b/apps/api/src/app/order/order.module.ts index 8f033058d..53d69c0f9 100644 --- a/apps/api/src/app/order/order.module.ts +++ b/apps/api/src/app/order/order.module.ts @@ -1,8 +1,8 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; -import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts index 3b4ee5d76..cf3dd2490 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -1,8 +1,8 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module'; +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; -import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 76aef0db1..85e914287 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1,3 +1,4 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; @@ -12,7 +13,6 @@ import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/ap import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; -import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index b3a916da9..aa835b00f 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -11,7 +11,11 @@ import { DataService } from '@ghostfolio/client/services/data.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { downloadAsFile } from '@ghostfolio/common/helper'; -import { HistoricalDataItem, User } from '@ghostfolio/common/interfaces'; +import { + AccountBalancesResponse, + HistoricalDataItem, + User +} from '@ghostfolio/common/interfaces'; import { OrderWithAccount } from '@ghostfolio/common/types'; import Big from 'big.js'; import { format, parseISO } from 'date-fns'; @@ -20,6 +24,7 @@ import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { AccountDetailDialogParams } from './interfaces/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @Component({ host: { class: 'd-flex flex-column h-100' }, @@ -29,11 +34,13 @@ import { AccountDetailDialogParams } from './interfaces/interfaces'; styleUrls: ['./account-detail-dialog.component.scss'] }) export class AccountDetailDialog implements OnDestroy, OnInit { + public accountBalances: AccountBalancesResponse['balances']; public activities: OrderWithAccount[]; public balance: number; public currency: string; public equity: number; public hasImpersonationId: boolean; + public hasPermissionToDeleteAccountBalance: boolean; public historicalDataItems: HistoricalDataItem[]; public isLoadingActivities: boolean; public isLoadingChart: boolean; @@ -59,6 +66,11 @@ export class AccountDetailDialog implements OnDestroy, OnInit { if (state?.user) { this.user = state.user; + this.hasPermissionToDeleteAccountBalance = hasPermission( + this.user.permissions, + permissions.deleteAccountBalance + ); + this.changeDetectorRef.markForCheck(); } }); @@ -66,7 +78,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit { public ngOnInit() { this.isLoadingActivities = true; - this.isLoadingChart = true; this.dataService .fetchAccount(this.data.accountId) @@ -112,48 +123,33 @@ export class AccountDetailDialog implements OnDestroy, OnInit { this.changeDetectorRef.markForCheck(); }); - this.dataService - .fetchPortfolioPerformance({ - filters: [ - { - id: this.data.accountId, - type: 'ACCOUNT' - } - ], - range: 'max', - withExcludedAccounts: true - }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ chart }) => { - this.historicalDataItems = chart.map( - ({ date, netWorth, netWorthInPercentage }) => { - return { - date, - value: - this.hasImpersonationId || this.user.settings.isRestrictedView - ? netWorthInPercentage - : netWorth - }; - } - ); - - this.isLoadingChart = false; - - this.changeDetectorRef.markForCheck(); - }); - this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((impersonationId) => { this.hasImpersonationId = !!impersonationId; }); + + this.fetchAccountBalances(); + this.fetchPortfolioPerformance(); } public onClose() { this.dialogRef.close(); } + public onDeleteAccountBalance(aId: string) { + this.dataService + .deleteAccountBalance(aId) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe({ + next: () => { + this.fetchAccountBalances(); + this.fetchPortfolioPerformance(); + } + }); + } + public onExport() { this.dataService .fetchExport( @@ -176,6 +172,51 @@ export class AccountDetailDialog implements OnDestroy, OnInit { }); } + private fetchAccountBalances() { + this.dataService + .fetchAccountBalances(this.data.accountId) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ balances }) => { + this.accountBalances = balances; + + this.changeDetectorRef.markForCheck(); + }); + } + + private fetchPortfolioPerformance() { + this.isLoadingChart = true; + + this.dataService + .fetchPortfolioPerformance({ + filters: [ + { + id: this.data.accountId, + type: 'ACCOUNT' + } + ], + range: 'max', + withExcludedAccounts: true + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ chart }) => { + this.historicalDataItems = chart.map( + ({ date, netWorth, netWorthInPercentage }) => { + return { + date, + value: + this.hasImpersonationId || this.user.settings.isRestrictedView + ? netWorthInPercentage + : netWorth + }; + } + ); + + this.isLoadingChart = false; + + this.changeDetectorRef.markForCheck(); + }); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index 7e92eca85..647ba0d6f 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -87,8 +87,11 @@ Cash Balances diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index c384cd476..e61fa2406 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -212,6 +212,10 @@ export class DataService { return this.http.delete(`/api/v1/account/${aId}`); } + public deleteAccountBalance(aId: string) { + return this.http.delete(`/api/v1/account-balance/${aId}`); + } + public deleteAllOrders() { return this.http.delete(`/api/v1/order/`); } diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 362d15b9c..51b653f3f 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -12,6 +12,7 @@ export const permissions = { createUserAccount: 'createUserAccount', deleteAccess: 'deleteAccess', deleteAccount: 'deleteAcccount', + deleteAccountBalance: 'deleteAcccountBalance', deleteAuthDevice: 'deleteAuthDevice', deleteOrder: 'deleteOrder', deletePlatform: 'deletePlatform', @@ -45,6 +46,7 @@ export function getPermissions(aRole: Role): string[] { permissions.accessAssistant, permissions.createAccess, permissions.createAccount, + permissions.deleteAccountBalance, permissions.createOrder, permissions.createPlatform, permissions.createTag, @@ -75,6 +77,7 @@ export function getPermissions(aRole: Role): string[] { permissions.createOrder, permissions.deleteAccess, permissions.deleteAccount, + permissions.deleteAccountBalance, permissions.deleteAuthDevice, permissions.deleteOrder, permissions.updateAccount, diff --git a/libs/ui/src/lib/account-balances/account-balances.component.html b/libs/ui/src/lib/account-balances/account-balances.component.html index 81f8a8192..291f11529 100644 --- a/libs/ui/src/lib/account-balances/account-balances.component.html +++ b/libs/ui/src/lib/account-balances/account-balances.component.html @@ -31,6 +31,29 @@ + + + + + + + + + + diff --git a/libs/ui/src/lib/account-balances/account-balances.component.ts b/libs/ui/src/lib/account-balances/account-balances.component.ts index c552519d6..4bcf7b26a 100644 --- a/libs/ui/src/lib/account-balances/account-balances.component.ts +++ b/libs/ui/src/lib/account-balances/account-balances.component.ts @@ -1,18 +1,19 @@ import { ChangeDetectionStrategy, - ChangeDetectorRef, Component, + EventEmitter, Input, + OnChanges, OnDestroy, OnInit, + Output, ViewChild } from '@angular/core'; import { MatSort } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; -import { DataService } from '@ghostfolio/client/services/data.service'; import { AccountBalancesResponse } from '@ghostfolio/common/interfaces'; import { get } from 'lodash'; -import { Subject, takeUntil } from 'rxjs'; +import { Subject } from 'rxjs'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -20,44 +21,48 @@ import { Subject, takeUntil } from 'rxjs'; styleUrls: ['./account-balances.component.scss'], templateUrl: './account-balances.component.html' }) -export class AccountBalancesComponent implements OnDestroy, OnInit { +export class AccountBalancesComponent implements OnChanges, OnDestroy, OnInit { + @Input() accountBalances: AccountBalancesResponse['balances']; @Input() accountId: string; @Input() locale: string; + @Input() showActions = true; + + @Output() accountBalanceDeleted = new EventEmitter(); @ViewChild(MatSort) sort: MatSort; public dataSource: MatTableDataSource< AccountBalancesResponse['balances'][0] > = new MatTableDataSource(); - public displayedColumns: string[] = ['date', 'value']; + public displayedColumns: string[] = ['date', 'value', 'actions']; private unsubscribeSubject = new Subject(); - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService - ) {} + public constructor() {} - public ngOnInit() { - this.fetchBalances(); - } + public ngOnInit() {} - public ngOnDestroy() { - this.unsubscribeSubject.next(); - this.unsubscribeSubject.complete(); + public ngOnChanges() { + if (this.accountBalances) { + this.dataSource = new MatTableDataSource(this.accountBalances); + + this.dataSource.sort = this.sort; + this.dataSource.sortingDataAccessor = get; + } } - private fetchBalances() { - this.dataService - .fetchAccountBalances(this.accountId) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ balances }) => { - this.dataSource = new MatTableDataSource(balances); + public onDeleteAccountBalance(aId: string) { + const confirmation = confirm( + $localize`Do you really want to delete this account balance?` + ); - this.dataSource.sort = this.sort; - this.dataSource.sortingDataAccessor = get; + if (confirmation) { + this.accountBalanceDeleted.emit(aId); + } + } - this.changeDetectorRef.markForCheck(); - }); + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); } } diff --git a/libs/ui/src/lib/account-balances/account-balances.module.ts b/libs/ui/src/lib/account-balances/account-balances.module.ts index cc8fb9677..210151cb2 100644 --- a/libs/ui/src/lib/account-balances/account-balances.module.ts +++ b/libs/ui/src/lib/account-balances/account-balances.module.ts @@ -1,5 +1,7 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatMenuModule } from '@angular/material/menu'; import { MatSortModule } from '@angular/material/sort'; import { MatTableModule } from '@angular/material/table'; import { GfValueModule } from '@ghostfolio/ui/value'; @@ -9,7 +11,14 @@ import { AccountBalancesComponent } from './account-balances.component'; @NgModule({ declarations: [AccountBalancesComponent], exports: [AccountBalancesComponent], - imports: [CommonModule, GfValueModule, MatSortModule, MatTableModule], + imports: [ + CommonModule, + GfValueModule, + MatButtonModule, + MatMenuModule, + MatSortModule, + MatTableModule + ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class GfAccountBalancesModule {}