diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fbbd465d..0fe9d8de5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Migrated the abstract _Material_ form field from a component to a directive - Improved the check for duplicates in the preview step of the activities import (regardless of the account) - Improved the check for duplicates in the preview step of the import dividends dialog (regardless of the account) - Extended the activities import to reuse an existing account of the user by name and currency @@ -18,6 +17,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed the check for duplicates in the preview step of the activities import for activities without a comment +## 3.44.0 - 2026-08-07 + +### Added + +- Added a live preview of the date and number format to the user settings +- Added the country flag to the currency selector +- Added a _Storybook_ story for the currency selector component +- Added the platform logo to the account selectors in the transfer cash balance dialog +- Extended the entity logo component by a `hasPlaceholder` attribute to reserve the space of a missing logo +- Warmed up the portfolio snapshot calculation in the background during the biometric authentication + +### Changed + +- Improved the usability of the create watchlist item dialog by setting the initial focus to the search field +- Migrated the abstract _Material_ form field from a component to a directive +- Removed the redundant `balance` attribute of the account in favor of the account balances + +### Fixed + +- Fixed the values of the charts and tables in impersonation mode with an unrestricted access to show absolute values instead of percentages +- Fixed the savings rate of the investment timeline chart and the streaks on the analysis page in impersonation mode to be based on the impersonated user +- Fixed the savings rate of the _FIRE_ calculator in impersonation mode to not be based on the impersonating user + ## 3.43.0 - 2026-08-06 ### Added diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 6466a13b2..f43aeedd5 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -156,32 +156,34 @@ export class AccountController { public async createAccount( @Body() data: CreateAccountDto ): Promise { - const { tags: tagIds, ...accountData } = data; + const { balance, tags: tagIds, ...accountData } = data; if (accountData.platformId) { const platformId = accountData.platformId; delete accountData.platformId; - return this.accountService.createAccount( - { + return this.accountService.createAccount({ + balance, + tagIds, + data: { ...accountData, platform: { connect: { id: platformId } }, user: { connect: { id: this.request.user.id } } }, - this.request.user.id, - tagIds - ); + userId: this.request.user.id + }); } else { delete accountData.platformId; - return this.accountService.createAccount( - { + return this.accountService.createAccount({ + balance, + tagIds, + data: { ...accountData, user: { connect: { id: this.request.user.id } } }, - this.request.user.id, - tagIds - ); + userId: this.request.user.id + }); } } @@ -257,52 +259,50 @@ export class AccountController { ); } - const { tags: tagIds, ...accountData } = data; + const { balance, tags: tagIds, ...accountData } = data; if (accountData.platformId) { const platformId = accountData.platformId; delete accountData.platformId; - return this.accountService.updateAccount( - { - data: { - ...accountData, - platform: { connect: { id: platformId } }, - user: { connect: { id: this.request.user.id } } - }, - where: { - id_userId: { - id, - userId: this.request.user.id - } - } + return this.accountService.updateAccount({ + balance, + tagIds, + data: { + ...accountData, + platform: { connect: { id: platformId } }, + user: { connect: { id: this.request.user.id } } }, - this.request.user.id, - tagIds - ); + userId: this.request.user.id, + where: { + id_userId: { + id, + userId: this.request.user.id + } + } + }); } else { // platformId is null, remove it delete accountData.platformId; - return this.accountService.updateAccount( - { - data: { - ...accountData, - platform: originalAccount.platformId - ? { disconnect: true } - : undefined, - user: { connect: { id: this.request.user.id } } - }, - where: { - id_userId: { - id, - userId: this.request.user.id - } - } + return this.accountService.updateAccount({ + balance, + tagIds, + data: { + ...accountData, + platform: originalAccount.platformId + ? { disconnect: true } + : undefined, + user: { connect: { id: this.request.user.id } } }, - this.request.user.id, - tagIds - ); + userId: this.request.user.id, + where: { + id_userId: { + id, + userId: this.request.user.id + } + } + }); } } } diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index 7f0451101..3d0bb91bd 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -10,6 +10,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; +import { AccountWithBalance } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; @@ -24,7 +25,7 @@ import { } from '@prisma/client'; import { Big } from 'big.js'; import { endOfToday, format } from 'date-fns'; -import { groupBy } from 'lodash'; +import { groupBy, isNil } from 'lodash'; import { CashDetails } from './interfaces/cash-details.interface'; @@ -40,7 +41,7 @@ export class AccountService { public async account({ id_userId - }: Prisma.AccountWhereUniqueInput): Promise { + }: Prisma.AccountWhereUniqueInput): Promise { const account = await this.prismaService.account.findUnique({ include: { balances: { @@ -87,7 +88,7 @@ export class AccountService { where?: Prisma.AccountWhereInput; orderBy?: Prisma.AccountOrderByWithRelationInput; }): Promise< - (Account & { + (AccountWithBalance & { activities?: (Order & { SymbolProfile?: SymbolProfile })[]; balances?: AccountBalance[]; platform?: Platform; @@ -160,12 +161,18 @@ export class AccountService { }); } - public async createAccount( - data: Prisma.AccountCreateInput, - aUserId: string, - tagIds?: string[] - ): Promise { - await this.tagService.validateTagIds({ tagIds, userId: aUserId }); + public async createAccount({ + balance, + data, + tagIds, + userId + }: { + balance?: number; + data: Prisma.AccountCreateInput; + tagIds?: string[]; + userId: string; + }): Promise { + await this.tagService.validateTagIds({ tagIds, userId }); const account = await this.prismaService.account.create({ data: { @@ -182,12 +189,14 @@ export class AccountService { } }); - await this.accountBalanceService.createOrUpdateAccountBalance({ - accountId: account.id, - balance: data.balance, - date: format(new Date(), DATE_FORMAT), - userId: aUserId - }); + if (!isNil(balance)) { + await this.accountBalanceService.createOrUpdateAccountBalance({ + balance, + userId, + accountId: account.id, + date: format(new Date(), DATE_FORMAT) + }); + } this.eventEmitter.emit( PortfolioChangedEvent.getName(), @@ -216,7 +225,7 @@ export class AccountService { return account; } - public async getAccounts(aUserId: string): Promise { + public async getAccounts(aUserId: string): Promise { const accounts = await this.accounts({ include: { activities: true, @@ -295,17 +304,20 @@ export class AccountService { }; } - public async updateAccount( - params: { - data: Prisma.AccountUpdateInput; - where: Prisma.AccountWhereUniqueInput; - }, - aUserId: string, - tagIds?: string[] - ): Promise { - const { data, where } = params; - - await this.tagService.validateTagIds({ tagIds, userId: aUserId }); + public async updateAccount({ + balance, + data, + tagIds, + userId, + where + }: { + balance?: number; + data: Prisma.AccountUpdateInput; + tagIds?: string[]; + userId: string; + where: Prisma.AccountWhereUniqueInput; + }): Promise { + await this.tagService.validateTagIds({ tagIds, userId }); const account = await this.prismaService.account.update({ data: { @@ -324,12 +336,14 @@ export class AccountService { where }); - await this.accountBalanceService.createOrUpdateAccountBalance({ - accountId: account.id, - balance: data.balance as number, - date: format(new Date(), DATE_FORMAT), - userId: aUserId - }); + if (!isNil(balance)) { + await this.accountBalanceService.createOrUpdateAccountBalance({ + balance, + userId, + accountId: account.id, + date: format(new Date(), DATE_FORMAT) + }); + } this.eventEmitter.emit( PortfolioChangedEvent.getName(), diff --git a/apps/api/src/app/account/interfaces/cash-details.interface.ts b/apps/api/src/app/account/interfaces/cash-details.interface.ts index 715343766..b396328a5 100644 --- a/apps/api/src/app/account/interfaces/cash-details.interface.ts +++ b/apps/api/src/app/account/interfaces/cash-details.interface.ts @@ -1,6 +1,6 @@ -import { Account } from '@prisma/client'; +import { AccountWithBalance } from '@ghostfolio/common/types'; export interface CashDetails { - accounts: Account[]; + accounts: AccountWithBalance[]; balanceInBaseCurrency: number; } diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 1d6990307..ddc41abad 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -1,14 +1,17 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { Logger, Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; @@ -24,14 +27,17 @@ import { OidcStrategy } from './oidc.strategy'; @Module({ controllers: [AuthController], imports: [ + ApiModule, ConfigurationModule, FetchModule, JwtModule.register({ secret: process.env.JWT_SECRET_KEY, signOptions: { expiresIn: '180 days' } }), + PortfolioSnapshotQueueModule, PrismaModule, PropertyModule, + RedisCacheModule, SubscriptionModule, UserModule ], diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts index 5764eeece..cb9dd8cb7 100644 --- a/apps/api/src/app/auth/web-auth.service.ts +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -1,6 +1,15 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { + PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW, + PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS +} from '@ghostfolio/common/config'; import { AuthDeviceDto } from '@ghostfolio/common/dtos'; import { AssertionCredentialJSON, @@ -29,6 +38,7 @@ import { VerifyRegistrationResponseOpts } from '@simplewebauthn/server'; import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'; +import { isPast } from 'date-fns'; import ms from 'ms'; @Injectable() @@ -36,9 +46,12 @@ export class WebAuthService { private readonly logger = new Logger(WebAuthService.name); public constructor( + private readonly apiService: ApiService, private readonly configurationService: ConfigurationService, private readonly deviceService: AuthDeviceService, private readonly jwtService: JwtService, + private readonly portfolioSnapshotService: PortfolioSnapshotService, + private readonly redisCacheService: RedisCacheService, private readonly userService: UserService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @@ -155,6 +168,9 @@ export class WebAuthService { throw new Error('Device not found'); } + // Compute in the background during the biometric authentication + void this.warmUpPortfolioSnapshot({ userId: device.userId }); + const opts: GenerateAuthenticationOptionsOpts = { allowCredentials: [], rpID: this.rpID, @@ -233,4 +249,57 @@ export class WebAuthService { throw new Error(); } + + private async isPortfolioSnapshotExpired(portfolioSnapshotKey: string) { + try { + const { expiration }: PortfolioSnapshotValue = JSON.parse( + await this.redisCacheService.get(portfolioSnapshotKey) + ); + + return isPast(new Date(expiration)); + } catch { + return true; + } + } + + private async warmUpPortfolioSnapshot({ userId }: { userId: string }) { + try { + const user = await this.userService.user({ id: userId }); + + if (!user) { + return; + } + + const userSettings = user.settings.settings; + + const filters = this.apiService.buildFiltersFromUserSettings({ + userSettings + }); + + const portfolioSnapshotKey = + this.redisCacheService.getPortfolioSnapshotKey({ filters, userId }); + + if (await this.isPortfolioSnapshotExpired(portfolioSnapshotKey)) { + await this.portfolioSnapshotService.addJobToQueue({ + data: { + filters, + userId, + calculationType: userSettings.performanceCalculationType, + userCurrency: userSettings.baseCurrency + }, + name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + opts: { + ...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, + jobId: portfolioSnapshotKey, + priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW + } + }); + } + } catch (error) { + this.logger.error( + `Portfolio snapshot of user '${userId}' could not be warmed up`, + error + ); + } + } } diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index 02ccb46b3..35db20993 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -102,7 +102,6 @@ export class ExportService { }) .map( ({ - balance, balances, comment, currency, @@ -111,13 +110,12 @@ export class ExportService { platform, platformId, tags - }) => { + }): ExportResponse['accounts'][number] => { if (platformId) { platformsMap[platformId] = platform; } return { - balance, balances: balances.map(({ date, value }) => { return { date: date.toISOString(), value }; }), diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index b6d90ff05..a9cde1544 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -411,6 +411,7 @@ export class ImportService { ); const account = omit(accountWithBalances, [ + 'balance', 'balances', 'isExcluded', 'tags' @@ -464,11 +465,12 @@ export class ImportService { }; } - const newAccount = await this.accountService.createAccount( - accountObject, - user.id, - tagIds - ); + const newAccount = await this.accountService.createAccount({ + tagIds, + balance: accountWithBalances.balance, + data: accountObject, + userId: user.id + }); // Store the new to old account ID mappings for updating activities if (accountWithSameIdOfOtherUser && oldAccountId) { diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index f6e8648d5..953976a4a 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -136,7 +136,7 @@ export class PortfolioController { if ( hasReadRestrictedAccessPermission({ impersonationId, - user: this.request.user + accesses: this.request.user?.accessesGet }) || isRestrictedView(this.request.user) ) { @@ -180,7 +180,7 @@ export class PortfolioController { hasDetails === false || hasReadRestrictedAccessPermission({ impersonationId, - user: this.request.user + accesses: this.request.user?.accessesGet }) || isRestrictedView(this.request.user) ) { @@ -374,7 +374,7 @@ export class PortfolioController { if ( hasReadRestrictedAccessPermission({ impersonationId, - user: this.request.user + accesses: this.request.user?.accessesGet }) || isRestrictedView(this.request.user) ) { @@ -491,19 +491,19 @@ export class PortfolioController { filterByTags: tags }); - let { investments, streaks } = await this.portfolioService.getInvestments({ - filters, - groupBy, - impersonationId, - dateRange: range, - savingsRate: this.request.user?.settings?.settings.savingsRate, - userId: this.request.user.id - }); + let { investments, savingsRate, streaks } = + await this.portfolioService.getInvestments({ + filters, + groupBy, + impersonationId, + dateRange: range, + userId: this.request.user.id + }); if ( hasReadRestrictedAccessPermission({ impersonationId, - user: this.request.user + accesses: this.request.user?.accessesGet }) || isRestrictedView(this.request.user) ) { @@ -521,6 +521,8 @@ export class PortfolioController { 'currentStreak', 'longestStreak' ]); + + savingsRate = null; } if ( @@ -537,7 +539,7 @@ export class PortfolioController { ]); } - return { investments, streaks }; + return { investments, savingsRate, streaks }; } @Get('performance') @@ -578,7 +580,7 @@ export class PortfolioController { if ( hasReadRestrictedAccessPermission({ impersonationId, - user: this.request.user + accesses: this.request.user?.accessesGet }) || isRestrictedView(this.request.user) || this.request.user.settings.settings.viewMode === 'ZEN' diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 97635553f..eed3a27cb 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -16,8 +16,9 @@ import { AssetProfileIdentifier, PortfolioSummary } from '@ghostfolio/common/interfaces'; +import { AccountWithBalance } from '@ghostfolio/common/types'; -import { Account, DataSource } from '@prisma/client'; +import { DataSource } from '@prisma/client'; import { Big } from 'big.js'; import { randomUUID } from 'node:crypto'; @@ -219,7 +220,7 @@ describe('PortfolioService', () => { it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => { const accountId = randomUUID(); - const cashAccount: Account = { + const cashAccount: AccountWithBalance = { balance: 2000, comment: null, createdAt: parseDate('2024-01-01'), @@ -444,7 +445,7 @@ describe('PortfolioService', () => { beforeEach(() => { jest .spyOn(accountService, 'getAccounts') - .mockResolvedValue([account] as unknown as Account[]); + .mockResolvedValue([account] as unknown as AccountWithBalance[]); jest .spyOn(exchangeRateDataService, 'toCurrency') diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 70106bdc1..48ea66dac 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -64,6 +64,7 @@ import { } from '@ghostfolio/common/interfaces'; import { TimelinePosition } from '@ghostfolio/common/models'; import { + AccountWithBalance, AccountWithValue, DateRange, GroupBy, @@ -75,7 +76,6 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance import { Inject, Injectable, Logger } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { - Account, Type as ActivityType, AssetClass, AssetSubClass, @@ -413,19 +413,18 @@ export class PortfolioService { filters, groupBy, impersonationId, - savingsRate, userId }: { dateRange: DateRange; filters?: Filter[]; groupBy?: GroupBy; impersonationId: string; - savingsRate: number; userId: string; }): Promise { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); + const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate; const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); @@ -438,6 +437,7 @@ export class PortfolioService { if (activities.length === 0) { return { + savingsRate, investments: [], streaks: { currentStreak: 0, longestStreak: 0 } }; @@ -484,6 +484,7 @@ export class PortfolioService { return { investments, + savingsRate, streaks }; } @@ -2142,7 +2143,7 @@ export class PortfolioService { const accounts: PortfolioDetails['accounts'] = {}; const platforms: PortfolioDetails['platforms'] = {}; - let currentAccounts: (Account & { + let currentAccounts: (AccountWithBalance & { Order?: Order[]; platform?: Platform; tags?: Tag[]; diff --git a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts index 60b994cac..6a9596298 100644 --- a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts +++ b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts @@ -38,7 +38,7 @@ export class RedactValuesInResponseInterceptor implements NestInterceptor< if ( hasReadRestrictedAccessPermission({ impersonationId, - user + accesses: user?.accessesGet }) || isRestrictedView(user) ) { diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index fd17bcd6e..65b5e95b0 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -57,12 +57,12 @@ export class GfAppComponent implements OnInit { public currentRoute: string; public currentSubRoute: string; public deviceType: string; - public hasImpersonationId: boolean; public hasInfoMessage: boolean; public hasPermissionToChangeDateRange: boolean; public hasPermissionToChangeFilters: boolean; public hasPromotion = false; public hasTabs = false; + public impersonationId: string | null; public info: InfoItem; public pageTitle: string; public routerLinkRegister = publicRoutes.register.routerLink; @@ -116,7 +116,7 @@ export class GfAppComponent implements OnInit { .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { - this.hasImpersonationId = !!impersonationId; + this.impersonationId = impersonationId; }); this.router.events @@ -291,13 +291,12 @@ export class GfAppComponent implements OnInit { baseCurrency: this.user?.settings?.baseCurrency, colorScheme: this.user?.settings?.colorScheme, deviceType: this.deviceType, - hasImpersonationId: this.hasImpersonationId, hasPermissionToAccessAdminControl: hasPermission( this.user?.permissions, permissions.accessAdminControl ), hasPermissionToCreateActivity: - !this.hasImpersonationId && + !this.impersonationId && hasPermission( this.user?.permissions, permissions.createActivity @@ -308,12 +307,13 @@ export class GfAppComponent implements OnInit { permissions.reportDataGlitch ), hasPermissionToUpdateActivity: - !this.hasImpersonationId && + !this.impersonationId && hasPermission( this.user?.permissions, permissions.updateActivity ) && !this.user?.settings?.isRestrictedView, + impersonationId: this.impersonationId, locale: this.user?.settings?.locale }, height: this.deviceType === 'mobile' ? '98vh' : '80vh', 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 e81b8cf06..a0350ee6b 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 @@ -14,7 +14,11 @@ import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; -import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { + hasPermission, + hasReadRestrictedAccessPermission, + permissions +} from '@ghostfolio/common/permissions'; import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; @@ -225,7 +229,10 @@ export class GfAccountDetailDialogComponent implements OnInit { protected showValuesInPercentage() { return ( - this.data.hasImpersonationId || this.user?.settings?.isRestrictedView + hasReadRestrictedAccessPermission({ + accesses: this.user?.access, + impersonationId: this.data.impersonationId + }) || this.user?.settings?.isRestrictedView ); } 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 485af7500..cb3246c00 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 @@ -158,8 +158,8 @@ [pageSize]="pageSize" [showAccountColumn]="false" [showActions]=" - !data.hasImpersonationId && data.hasPermissionToCreateActivity && + !data.impersonationId && user?.settings?.isExperimentalFeatures && !user?.settings?.isRestrictedView " @@ -183,8 +183,8 @@ [currentBalance]="balance" [locale]="user?.settings?.locale" [showActions]=" - !data.hasImpersonationId && hasPermissionToDeleteAccountBalance && + !data.impersonationId && !user.settings.isRestrictedView " (accountBalanceCreated)="onAddAccountBalance($event)" diff --git a/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts index 2f80dac36..0e7d04f2c 100644 --- a/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts @@ -1,8 +1,8 @@ export interface AccountDetailDialogParams { accountId: string; deviceType: string; - hasImpersonationId: boolean; hasPermissionToCreateActivity: boolean; + impersonationId: string | null; } export interface AccountDetailDialogResult { diff --git a/apps/client/src/app/components/admin-platform/admin-platform.component.html b/apps/client/src/app/components/admin-platform/admin-platform.component.html index 19682bdc0..7c699e557 100644 --- a/apps/client/src/app/components/admin-platform/admin-platform.component.html +++ b/apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -22,13 +22,12 @@ Name - @if (element.url) { - - } + {{ element.name }} diff --git a/apps/client/src/app/components/admin-settings/admin-settings.component.html b/apps/client/src/app/components/admin-settings/admin-settings.component.html index 99346f0fb..b3ad389d6 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.component.html +++ b/apps/client/src/app/components/admin-settings/admin-settings.component.html @@ -59,7 +59,11 @@
- +
@if (isGhostfolioDataProvider(element)) { (GfCreateWatchlistItemDialogComponent, { - autoFocus: false, data: { deviceType: this.deviceType(), locale: this.user?.settings?.locale ?? DEFAULT_LOCALE diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts index b48adf59d..18ce8e07c 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts @@ -22,6 +22,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, inject, @@ -50,6 +51,7 @@ import { format, parseISO } from 'date-fns'; import { addIcons } from 'ionicons'; import { eyeOffOutline, eyeOutline } from 'ionicons/icons'; import ms from 'ms'; +import { DeviceDetectorService } from 'ngx-device-detector'; import { EMPTY, throwError } from 'rxjs'; import { catchError } from 'rxjs/operators'; @@ -108,10 +110,17 @@ export class GfUserAccountSettingsComponent implements OnInit { 'uk', 'zh' ]; + protected readonly previewDate = new Date().toISOString(); + protected readonly previewValue = 9999.99; protected user: User; + protected readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly dataService = inject(DataService); + private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly destroyRef = inject(DestroyRef); private readonly notificationService = inject(NotificationService); private readonly settingsStorageService = inject(SettingsStorageService); diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html index 57d3b54d7..cb39360f7 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.html +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -146,7 +146,7 @@
-
+
Locale
@@ -154,7 +154,10 @@
- + {{ locale }} } + + + ยท + +
diff --git a/apps/client/src/app/pages/accounts/accounts-page.component.ts b/apps/client/src/app/pages/accounts/accounts-page.component.ts index 291a7f3f9..9b10fb222 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.component.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts @@ -12,6 +12,7 @@ import { } from '@ghostfolio/common/dtos'; import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { AccountWithValue } from '@ghostfolio/common/types'; import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table'; import { GfFabComponent } from '@ghostfolio/ui/fab'; import { NotificationService } from '@ghostfolio/ui/notifications'; @@ -29,7 +30,7 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { Account as AccountModel, Tag } from '@prisma/client'; +import { Tag } from '@prisma/client'; import { DeviceDetectorService } from 'ngx-device-detector'; import { EMPTY } from 'rxjs'; import { catchError } from 'rxjs/operators'; @@ -48,11 +49,11 @@ import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-ba templateUrl: './accounts-page.html' }) export class GfAccountsPageComponent implements OnInit { - protected accounts: AccountModel[]; + protected accounts: AccountWithValue[]; protected activitiesCount = 0; - protected hasImpersonationId: boolean; protected hasPermissionToCreateAccount: boolean; protected hasPermissionToUpdateAccount: boolean; + protected impersonationId: string | null; protected totalBalanceInBaseCurrency = 0; protected totalValueInBaseCurrency = 0; protected user: User; @@ -103,12 +104,16 @@ export class GfAccountsPageComponent implements OnInit { }); } + protected get hasImpersonationId() { + return !!this.impersonationId; + } + public ngOnInit() { this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { - this.hasImpersonationId = !!impersonationId; + this.impersonationId = impersonationId; }); this.userService.stateChanged @@ -155,7 +160,7 @@ export class GfAccountsPageComponent implements OnInit { }); } - protected onUpdateAccount(aAccount: AccountModel) { + protected onUpdateAccount(aAccount: AccountWithValue) { this.router.navigate([], { queryParams: { accountId: aAccount.id, editDialog: true } }); @@ -194,7 +199,7 @@ export class GfAccountsPageComponent implements OnInit { name, platformId, tags - }: AccountModel & { tags?: Tag[] }) { + }: AccountWithValue & { tags?: Tag[] }) { const dialogRef = this.dialog.open< GfCreateOrUpdateAccountDialogComponent, CreateOrUpdateAccountDialogParams @@ -251,11 +256,11 @@ export class GfAccountsPageComponent implements OnInit { data: { accountId: aAccountId, deviceType: this.deviceType(), - hasImpersonationId: this.hasImpersonationId, hasPermissionToCreateActivity: !this.hasImpersonationId && hasPermission(this.user?.permissions, permissions.createActivity) && - !this.user?.settings?.isRestrictedView + !this.user?.settings?.isRestrictedView, + impersonationId: this.impersonationId }, height: this.deviceType() === 'mobile' ? '98vh' : '80vh', width: this.deviceType() === 'mobile' ? '100vw' : '50rem' diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html index 85cd9dcfe..86a034b53 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -46,10 +46,11 @@
Platform - @if (selectedPlatform?.url) { + @if (selectedPlatform) { @@ -71,6 +72,7 @@ diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts index c51503277..43015de00 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts @@ -1,9 +1,13 @@ import { User } from '@ghostfolio/common/interfaces'; +import { AccountWithBalance } from '@ghostfolio/common/types'; -import { Account, Tag } from '@prisma/client'; +import { Tag } from '@prisma/client'; export interface CreateOrUpdateAccountDialogParams { - account: Omit & { + account: Omit< + AccountWithBalance, + 'createdAt' | 'id' | 'updatedAt' | 'userId' + > & { id: string | null; tags?: Tag[]; }; diff --git a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts index cbf0e460d..1682874dc 100644 --- a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts +++ b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts @@ -61,12 +61,22 @@ export class GfTransferBalanceDialogComponent { private readonly dialogRef = inject>(MatDialogRef); + protected get selectedFromAccount() { + return this.getAccountById( + this.transferBalanceForm.controls.fromAccount.value + ); + } + + protected get selectedToAccount() { + return this.getAccountById( + this.transferBalanceForm.controls.toAccount.value + ); + } + public ngOnInit() { this.transferBalanceForm.controls.fromAccount.valueChanges.subscribe( (id) => { - const currency = this.accounts.find((account) => { - return account.id === id; - })?.currency; + const currency = this.getAccountById(id)?.currency; if (currency) { this.currency = currency; @@ -101,4 +111,10 @@ export class GfTransferBalanceDialogComponent { return null; } + + private getAccountById(aId: string | null) { + return this.accounts.find(({ id }) => { + return id === aId; + }); + } } diff --git a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html index 50c96be86..941c9485a 100644 --- a/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html +++ b/apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html @@ -10,16 +10,29 @@ From + +
+ @if (selectedFromAccount) { + + } + {{ selectedFromAccount?.name }} +
+
+ @for (account of accounts; track account) {
- @if (account.platform?.url) { - - } + {{ account.name }}
@@ -31,16 +44,29 @@ To + +
+ @if (selectedToAccount) { + + } + {{ selectedToAccount?.name }} +
+
+ @for (account of accounts; track account) {
- @if (account.platform?.url) { - - } + {{ account.name }}
diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html index b3c708167..9455a9dd6 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html @@ -87,9 +87,10 @@
- @if (selectedAccount?.platform?.url) { + @if (selectedAccount) { @@ -103,13 +104,12 @@ @for (account of data.accounts; track account) {
- @if (account.platform?.url) { - - } + {{ account.name }}
diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index 0931578cf..be7d49bea 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -17,7 +17,11 @@ import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; -import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { + hasPermission, + hasReadRestrictedAccessPermission, + permissions +} from '@ghostfolio/common/permissions'; import { MarketAdvanced } from '@ghostfolio/common/types'; import { translate } from '@ghostfolio/ui/i18n'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; @@ -85,7 +89,6 @@ export class GfAllocationsPageComponent implements OnInit { protected readonly deviceType = computed( () => this.deviceDetectorService.deviceInfo().deviceType ); - protected hasImpersonationId: boolean; protected holdings: { [symbol: string]: Pick< PortfolioPosition['assetProfile'], @@ -97,6 +100,7 @@ export class GfAllocationsPageComponent implements OnInit { | 'name' > & { etfProvider: string; value: number }; }; + protected impersonationId: string | null; protected isLoading = false; protected markets: PortfolioDetails['markets']; protected marketsAdvanced: { @@ -169,7 +173,7 @@ export class GfAllocationsPageComponent implements OnInit { .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { - this.hasImpersonationId = !!impersonationId; + this.impersonationId = impersonationId; this.changeDetectorRef.markForCheck(); }); @@ -224,7 +228,12 @@ export class GfAllocationsPageComponent implements OnInit { } protected showValuesInPercentage() { - return this.hasImpersonationId || this.user?.settings?.isRestrictedView; + return ( + hasReadRestrictedAccessPermission({ + accesses: this.user?.access, + impersonationId: this.impersonationId + }) || this.user?.settings?.isRestrictedView + ); } private extractCurrency({ @@ -618,11 +627,11 @@ export class GfAllocationsPageComponent implements OnInit { data: { accountId: aAccountId, deviceType: this.deviceType(), - hasImpersonationId: this.hasImpersonationId, hasPermissionToCreateActivity: - !this.hasImpersonationId && + !this.impersonationId && hasPermission(this.user?.permissions, permissions.createActivity) && - !this.user?.settings?.isRestrictedView + !this.user?.settings?.isRestrictedView, + impersonationId: this.impersonationId }, height: this.deviceType() === 'mobile' ? '98vh' : '80vh', width: this.deviceType() === 'mobile' ? '100vw' : '50rem' diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index c2d23dbc6..424f1bf5b 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -16,7 +16,11 @@ import { ToggleOption, User } from '@ghostfolio/common/interfaces'; -import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { + hasPermission, + hasReadRestrictedAccessPermission, + permissions +} from '@ghostfolio/common/permissions'; import type { AiPromptMode, GroupBy } from '@ghostfolio/common/types'; import { translate } from '@ghostfolio/ui/i18n'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; @@ -79,8 +83,8 @@ export class GfAnalysisPageComponent implements OnInit { protected bottom3: PortfolioPosition[]; protected dividendsByGroup: InvestmentItem[]; protected readonly dividendTimelineDataLabel = $localize`Dividend`; - protected hasImpersonationId: boolean; protected hasPermissionToReadAiPrompt: boolean; + protected impersonationId: string | null; protected investments: InvestmentItem[]; protected readonly investmentTimelineDataLabel = $localize`Invested Capital`; protected investmentsByGroup: InvestmentItem[]; @@ -100,6 +104,7 @@ export class GfAnalysisPageComponent implements OnInit { protected performanceDataItemsInPercentage: HistoricalDataItem[]; protected readonly portfolioEvolutionDataLabel = $localize`Investment`; protected precision = 2; + protected savingsRatePerMonth: number | undefined; protected streaks: PortfolioInvestmentsResponse['streaks']; protected top3: PortfolioPosition[]; protected unitCurrentStreak: string; @@ -131,18 +136,13 @@ export class GfAnalysisPageComponent implements OnInit { } get savingsRate() { - const savingsRatePerMonth = - this.hasImpersonationId || this.user.settings.isRestrictedView - ? undefined - : this.user?.settings?.savingsRate; - - if (savingsRatePerMonth === undefined) { + if (!this.savingsRatePerMonth) { return undefined; } return this.mode() === 'year' - ? savingsRatePerMonth * 12 - : savingsRatePerMonth; + ? this.savingsRatePerMonth * 12 + : this.savingsRatePerMonth; } public ngOnInit() { @@ -150,7 +150,7 @@ export class GfAnalysisPageComponent implements OnInit { .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { - this.hasImpersonationId = !!impersonationId; + this.impersonationId = impersonationId; this.changeDetectorRef.markForCheck(); }); @@ -241,6 +241,15 @@ export class GfAnalysisPageComponent implements OnInit { }); } + protected showValuesInPercentage() { + return ( + hasReadRestrictedAccessPermission({ + accesses: this.user?.access, + impersonationId: this.impersonationId + }) || this.user?.settings?.isRestrictedView + ); + } + private fetchDividendsAndInvestments() { this.isLoadingDividendTimelineChart = true; this.isLoadingInvestmentTimelineChart = true; @@ -267,8 +276,9 @@ export class GfAnalysisPageComponent implements OnInit { range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ investments, streaks }) => { + .subscribe(({ investments, savingsRate, streaks }) => { this.investmentsByGroup = investments; + this.savingsRatePerMonth = savingsRate; this.streaks = streaks; this.unitCurrentStreak = this.mode() === 'year' diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index 0fac204f9..82751b882 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -398,9 +398,7 @@ [benchmarkDataLabel]="portfolioEvolutionDataLabel" [currency]="user?.settings?.baseCurrency" [historicalDataItems]="performanceDataItems" - [isInPercentage]=" - hasImpersonationId || user.settings.isRestrictedView - " + [isInPercentage]="showValuesInPercentage()" [isLoading]="isLoadingInvestmentChart" [locale]="user?.settings?.locale" /> @@ -456,9 +454,7 @@ [benchmarkDataLabel]="investmentTimelineDataLabel" [currency]="user?.settings?.baseCurrency" [groupBy]="mode()" - [isInPercentage]=" - hasImpersonationId || user.settings.isRestrictedView - " + [isInPercentage]="showValuesInPercentage()" [isLoading]="isLoadingInvestmentTimelineChart" [locale]="user?.settings?.locale" [savingsRate]="savingsRate" @@ -493,9 +489,7 @@ [benchmarkDataLabel]="dividendTimelineDataLabel" [currency]="user?.settings?.baseCurrency" [groupBy]="mode()" - [isInPercentage]=" - hasImpersonationId || user.settings.isRestrictedView - " + [isInPercentage]="showValuesInPercentage()" [isLoading]="isLoadingDividendTimelineChart" [locale]="user?.settings?.locale" /> diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.html b/apps/client/src/app/pages/portfolio/fire/fire-page.html index 7315f10cb..13693a15a 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.html +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.html @@ -21,7 +21,7 @@ [locale]="user?.settings?.locale" [projectedTotalAmount]="user?.settings?.projectedTotalAmount" [retirementDate]="user?.settings?.retirementDate" - [savingsRate]="user?.settings?.savingsRate" + [savingsRate]="hasImpersonationId ? 0 : user?.settings?.savingsRate" [style.opacity]=" user?.subscription?.type === 'Basic' ? '0.67' : 'initial' " diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 08a6701dc..23c8aab69 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -115,7 +115,6 @@ export const DEFAULT_REDACTED_PATHS = [ 'accounts[*].interestInBaseCurrency', 'accounts[*].value', 'accounts[*].valueInBaseCurrency', - 'activities[*].account.balance', 'activities[*].account.comment', 'activities[*].assetProfile.symbolMapping', 'activities[*].assetProfile.watchedByCount', @@ -128,7 +127,6 @@ export const DEFAULT_REDACTED_PATHS = [ 'activities[*].valueInBaseCurrency', 'balance', 'balanceInBaseCurrency', - 'balances[*].account.balance', 'balances[*].account.comment', 'balances[*].value', 'balances[*].valueInBaseCurrency', diff --git a/libs/common/src/lib/dtos/create-account.dto.ts b/libs/common/src/lib/dtos/create-account.dto.ts index cae8293ee..ccadff5f9 100644 --- a/libs/common/src/lib/dtos/create-account.dto.ts +++ b/libs/common/src/lib/dtos/create-account.dto.ts @@ -12,8 +12,13 @@ import { import { isString } from 'lodash'; export class CreateAccountDto { + /** + * The initial balance, stored as the account balance of today. + * Optional because callers may instead supply the full history via `balances`. + */ @IsNumber() - balance: number; + @IsOptional() + balance?: number; @IsOptional() @IsString() diff --git a/libs/common/src/lib/dtos/update-account.dto.ts b/libs/common/src/lib/dtos/update-account.dto.ts index d8bfc7b8d..4e1570aad 100644 --- a/libs/common/src/lib/dtos/update-account.dto.ts +++ b/libs/common/src/lib/dtos/update-account.dto.ts @@ -12,8 +12,13 @@ import { import { isString } from 'lodash'; export class UpdateAccountDto { + /** + * The balance, stored as the account balance of today. + * Optional because the account balances are the source of truth. + */ @IsNumber() - balance: number; + @IsOptional() + balance?: number; @IsOptional() @IsString() diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index 669c42e32..6cc090170 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -4,6 +4,7 @@ import { } from '@ghostfolio/common/config'; import { extractNumberFromString, + getCountryCodeFromCurrency, getNumberFormatGroup, getStringOrNull, getStringOrUndefined, @@ -77,6 +78,30 @@ describe('Helper', () => { }); }); + describe('Get country code from currency', () => { + it('ISO 4217 currency code', () => { + expect(getCountryCodeFromCurrency('CHF')).toEqual('CH'); + expect(getCountryCodeFromCurrency('USD')).toEqual('US'); + }); + + it('Currency of the European Union', () => { + expect(getCountryCodeFromCurrency('EUR')).toEqual('EU'); + }); + + it('Derived currency', () => { + expect(getCountryCodeFromCurrency('GBp')).toEqual('GB'); + }); + + it('Supranational currency', () => { + expect(getCountryCodeFromCurrency('XAU')).toEqual(''); + expect(getCountryCodeFromCurrency('XOF')).toEqual(''); + }); + + it('Empty currency', () => { + expect(getCountryCodeFromCurrency('')).toEqual(''); + }); + }); + describe('Get number format group', () => { let languageGetter: jest.SpyInstance; diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 0778f84f1..44fd89aa2 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -279,6 +279,17 @@ export function getCurrencyFromSymbol(aSymbol = '') { return aSymbol.replace(DEFAULT_CURRENCY, ''); } +export function getCountryCodeFromCurrency(aCurrency = '') { + // An ISO 4217 currency code is composed of the ISO 3166-1 alpha-2 country + // code and the initial of the currency itself, except for the supranational + // currencies, which are prefixed with X (like XAU or XOF) + if (aCurrency.startsWith('X')) { + return ''; + } + + return aCurrency.slice(0, 2).toUpperCase(); +} + export function getCountryName({ code }: { code: string }): string { try { return ( diff --git a/libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts b/libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts index 6d0d60002..30ac53765 100644 --- a/libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts +++ b/libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts @@ -2,5 +2,6 @@ import { InvestmentItem } from '../investment-item.interface'; export interface PortfolioInvestmentsResponse { investments: InvestmentItem[]; + savingsRate?: number; streaks: { currentStreak: number; longestStreak: number }; } diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 811ded68c..96533a9e7 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -1,6 +1,6 @@ import { UserWithSettings } from '@ghostfolio/common/types'; -import { Role } from '@prisma/client'; +import { Access, Role } from '@prisma/client'; export const permissions = { accessAdminControl: 'accessAdminControl', @@ -198,17 +198,17 @@ export function hasPermission( } export function hasReadRestrictedAccessPermission({ - impersonationId, - user + accesses = [], + impersonationId }: { - impersonationId: string; - user: UserWithSettings; + accesses?: Pick[]; + impersonationId: string | null; }) { if (!impersonationId) { return false; } - const access = user?.accessesGet?.find(({ id }) => { + const access = accesses.find(({ id }) => { return id === impersonationId; }); diff --git a/libs/common/src/lib/types/account-with-balance.type.ts b/libs/common/src/lib/types/account-with-balance.type.ts new file mode 100644 index 000000000..72732f3a6 --- /dev/null +++ b/libs/common/src/lib/types/account-with-balance.type.ts @@ -0,0 +1,5 @@ +import { Account as AccountModel } from '@prisma/client'; + +export type AccountWithBalance = AccountModel & { + balance: number; +}; diff --git a/libs/common/src/lib/types/account-with-value.type.ts b/libs/common/src/lib/types/account-with-value.type.ts index a13530632..27b7541dc 100644 --- a/libs/common/src/lib/types/account-with-value.type.ts +++ b/libs/common/src/lib/types/account-with-value.type.ts @@ -1,6 +1,8 @@ -import { Account as AccountModel, Platform, Tag } from '@prisma/client'; +import { Platform, Tag } from '@prisma/client'; -export type AccountWithValue = AccountModel & { +import { AccountWithBalance } from './account-with-balance.type'; + +export type AccountWithValue = AccountWithBalance & { activitiesCount: number; allocationInPercentage: number; balanceInBaseCurrency: number; diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts index aa6893bc6..f61bfdca7 100644 --- a/libs/common/src/lib/types/index.ts +++ b/libs/common/src/lib/types/index.ts @@ -1,5 +1,6 @@ import type { AccessType } from './access-type.type'; import type { AccessWithGranteeUser } from './access-with-grantee-user.type'; +import type { AccountWithBalance } from './account-with-balance.type'; import type { AccountWithPlatform } from './account-with-platform.type'; import type { AccountWithValue } from './account-with-value.type'; import type { AiPromptMode } from './ai-prompt-mode.type'; @@ -28,6 +29,7 @@ import type { ViewMode } from './view-mode.type'; export type { AccessType, AccessWithGranteeUser, + AccountWithBalance, AccountWithPlatform, AccountWithValue, AiPromptMode, diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.html b/libs/ui/src/lib/accounts-table/accounts-table.component.html index d252b1488..1d1cbb296 100644 --- a/libs/ui/src/lib/accounts-table/accounts-table.component.html +++ b/libs/ui/src/lib/accounts-table/accounts-table.component.html @@ -50,13 +50,12 @@ Name - @if (element.platform?.url) { - - } + {{ element.name }} Total @@ -98,13 +97,12 @@ mat-cell >
- @if (element.platform?.url) { - - } + {{ element.platform?.name }}
diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts b/libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts index a38ca826c..68da20ec6 100644 --- a/libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts +++ b/libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts @@ -1,3 +1,5 @@ +import { AccountWithValue } from '@ghostfolio/common/types'; + import { CommonModule } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; import { MatMenuModule } from '@angular/material/menu'; @@ -14,16 +16,18 @@ import { NotificationService } from '../notifications'; import { GfValueComponent } from '../value'; import { GfAccountsTableComponent } from './accounts-table.component'; -const accounts = [ +const accounts: AccountWithValue[] = [ { activitiesCount: 0, - allocationInPercentage: null, + allocationInPercentage: 0.002574748676949956, balance: 278, balanceInBaseCurrency: 278, comment: null, createdAt: new Date('2025-06-01T06:52:49.063Z'), currency: 'USD', + dividendInBaseCurrency: 0, id: '460d7401-ca43-4ed4-b08e-349f1822e9db', + interestInBaseCurrency: 0, name: 'Coinbase Account', platform: { id: '8dc24b88-bb92-4152-af25-fe6a31643e26', @@ -38,13 +42,15 @@ const accounts = [ }, { activitiesCount: 0, - allocationInPercentage: null, + allocationInPercentage: 0.11114023065971035, balance: 12000, balanceInBaseCurrency: 12000, comment: null, createdAt: new Date('2025-06-01T06:48:53.055Z'), currency: 'USD', + dividendInBaseCurrency: 0, id: '6d773e31-0583-4c85-a247-e69870b4f1ee', + interestInBaseCurrency: 0, name: 'Private Banking Account', platform: { id: '43e8fcd1-5b79-4100-b678-d2229bd1660d', @@ -59,13 +65,15 @@ const accounts = [ }, { activitiesCount: 12, - allocationInPercentage: null, + allocationInPercentage: 0.8862850206633397, balance: 150.2, balanceInBaseCurrency: 150.2, comment: null, createdAt: new Date('2025-05-31T13:00:13.940Z'), currency: 'USD', + dividendInBaseCurrency: 0, id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625', + interestInBaseCurrency: 0, name: 'Trading Account', platform: { id: '9da3a8a7-4795-43e3-a6db-ccb914189737', @@ -73,10 +81,10 @@ const accounts = [ url: 'https://interactivebrokers.com' }, platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737', - valueInBaseCurrency: 95693.70321466809, updatedAt: new Date('2025-06-01T06:53:10.569Z'), userId: '081aa387-487d-4438-83a4-3060eb2a016e', - value: 95693.70321466809 + value: 95693.70321466809, + valueInBaseCurrency: 95693.70321466809 } ]; diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.ts b/libs/ui/src/lib/accounts-table/accounts-table.component.ts index 3e531e844..ab49c8cb5 100644 --- a/libs/ui/src/lib/accounts-table/accounts-table.component.ts +++ b/libs/ui/src/lib/accounts-table/accounts-table.component.ts @@ -4,6 +4,7 @@ import { getLowercase, isAccountExcluded } from '@ghostfolio/common/helper'; +import { AccountWithValue } from '@ghostfolio/common/types'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { GfValueComponent } from '@ghostfolio/ui/value'; @@ -24,7 +25,6 @@ import { MatSort, MatSortModule } from '@angular/material/sort'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { Router, RouterModule } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; -import { Account } from '@prisma/client'; import { addIcons } from 'ionicons'; import { arrowRedoOutline, @@ -55,7 +55,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; templateUrl: './accounts-table.component.html' }) export class GfAccountsTableComponent { - public readonly accounts = input.required(); + public readonly accounts = input.required(); public readonly activitiesCount = input(); public readonly baseCurrency = input(); public readonly hasPermissionToOpenDetails = input(true); @@ -71,12 +71,12 @@ export class GfAccountsTableComponent { public readonly totalValueInBaseCurrency = input(); public readonly accountDeleted = output(); - public readonly accountToUpdate = output(); + public readonly accountToUpdate = output(); public readonly transferBalance = output(); public readonly sort = viewChild.required(MatSort); - protected readonly dataSource = new MatTableDataSource([]); + protected readonly dataSource = new MatTableDataSource([]); protected readonly displayedColumns = computed(() => { const columns = ['status', 'account', 'platform']; @@ -141,7 +141,9 @@ export class GfAccountsTableComponent { }); } - protected isExcluded(account: Account & { tags?: { id: string }[] }) { + protected isExcluded( + account: AccountWithValue & { tags?: { id: string }[] } + ) { return isAccountExcluded(account); } @@ -173,7 +175,7 @@ export class GfAccountsTableComponent { this.transferBalance.emit(); } - protected onUpdateAccount(aAccount: Account) { + protected onUpdateAccount(aAccount: AccountWithValue) { this.accountToUpdate.emit(aAccount); } } diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index bf7fb2caf..7f53da4fa 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -356,13 +356,12 @@
- @if (element.account?.platform?.url) { - - } + {{ element.account?.name }} diff --git a/libs/ui/src/lib/activities-table/activities-table.component.stories.ts b/libs/ui/src/lib/activities-table/activities-table.component.stories.ts index 0136545cf..929c71787 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.stories.ts +++ b/libs/ui/src/lib/activities-table/activities-table.component.stories.ts @@ -39,7 +39,6 @@ const activities: Activity[] = [ updatedAt: new Date('2025-05-31T18:43:01.840Z'), userId: '081aa387-487d-4438-83a4-3060eb2a016e', account: { - balance: 150.2, comment: null, createdAt: new Date('2025-05-31T13:00:13.940Z'), currency: 'USD', @@ -105,7 +104,6 @@ const activities: Activity[] = [ updatedAt: new Date('2025-05-31T18:46:14.175Z'), userId: '081aa387-487d-4438-83a4-3060eb2a016e', account: { - balance: 150.2, comment: null, createdAt: new Date('2025-05-31T13:00:13.940Z'), currency: 'USD', @@ -171,7 +169,6 @@ const activities: Activity[] = [ updatedAt: new Date('2025-05-31T18:49:54.064Z'), userId: '081aa387-487d-4438-83a4-3060eb2a016e', account: { - balance: 150.2, comment: null, createdAt: new Date('2025-05-31T13:00:13.940Z'), currency: 'USD', @@ -237,7 +234,6 @@ const activities: Activity[] = [ updatedAt: new Date('2025-05-31T18:48:48.209Z'), userId: '081aa387-487d-4438-83a4-3060eb2a016e', account: { - balance: 150.2, comment: null, createdAt: new Date('2025-05-31T13:00:13.940Z'), currency: 'USD', @@ -303,7 +299,6 @@ const activities: Activity[] = [ updatedAt: new Date('2025-05-31T18:46:44.616Z'), userId: '081aa387-487d-4438-83a4-3060eb2a016e', account: { - balance: 150.2, comment: null, createdAt: new Date('2025-05-31T13:00:13.940Z'), currency: 'USD', diff --git a/libs/ui/src/lib/currency-selector/currency-selector.component.html b/libs/ui/src/lib/currency-selector/currency-selector.component.html index e07101f9a..594515e24 100644 --- a/libs/ui/src/lib/currency-selector/currency-selector.component.html +++ b/libs/ui/src/lib/currency-selector/currency-selector.component.html @@ -1,9 +1,14 @@ +@if (emojiFlagOfSelectedCurrency) { + {{ emojiFlagOfSelectedCurrency }} +} + @for (currency of filteredCurrencies; track currency) { - {{ currency }} + + {{ getEmojiFlagFromCurrency(currency) }} + {{ currency }} + } diff --git a/libs/ui/src/lib/currency-selector/currency-selector.component.scss b/libs/ui/src/lib/currency-selector/currency-selector.component.scss deleted file mode 100644 index 5d4e87f30..000000000 --- a/libs/ui/src/lib/currency-selector/currency-selector.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -:host { - display: block; -} diff --git a/libs/ui/src/lib/currency-selector/currency-selector.component.stories.ts b/libs/ui/src/lib/currency-selector/currency-selector.component.stories.ts new file mode 100644 index 000000000..beb63e369 --- /dev/null +++ b/libs/ui/src/lib/currency-selector/currency-selector.component.stories.ts @@ -0,0 +1,99 @@ +import { ANIMATION_MODULE_TYPE } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import '@angular/localize/init'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; + +import { GfCurrencySelectorComponent } from './currency-selector.component'; + +const CURRENCIES = [ + 'AUD', + 'CHF', + 'EUR', + 'GBP', + 'GBp', + 'JPY', + 'USD', + 'XAU', + 'ZAR' +]; + +const meta: Meta = { + title: 'Currency Selector', + component: GfCurrencySelectorComponent, + decorators: [ + moduleMetadata({ + imports: [ + GfCurrencySelectorComponent, + MatFormFieldModule, + ReactiveFormsModule + ], + providers: [ + { + provide: ANIMATION_MODULE_TYPE, + useValue: 'NoopAnimations' + } + ] + }) + ], + render: ({ currencies, value }) => { + return { + props: { + currencies, + formGroup: new FormGroup({ + currency: new FormControl(value) + }) + }, + template: ` +
+ + Currency + + +
+ ` + }; + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + currencies: CURRENCIES, + value: 'CHF' + } +}; + +export const CurrencyOfEuropeanUnion: Story = { + args: { + currencies: CURRENCIES, + value: 'EUR' + } +}; + +export const DerivedCurrency: Story = { + args: { + currencies: CURRENCIES, + value: 'GBp' + } +}; + +export const SupranationalCurrency: Story = { + args: { + currencies: CURRENCIES, + value: 'XAU' + } +}; + +export const WithoutValue: Story = { + args: { + currencies: CURRENCIES, + value: null + } +}; diff --git a/libs/ui/src/lib/currency-selector/currency-selector.component.ts b/libs/ui/src/lib/currency-selector/currency-selector.component.ts index 724e86712..2eda80aba 100644 --- a/libs/ui/src/lib/currency-selector/currency-selector.component.ts +++ b/libs/ui/src/lib/currency-selector/currency-selector.component.ts @@ -1,3 +1,8 @@ +import { + getCountryCodeFromCurrency, + getEmojiFlag +} from '@ghostfolio/common/helper'; + import { FocusMonitor } from '@angular/cdk/a11y'; import { CUSTOM_ELEMENTS_SCHEMA, @@ -24,9 +29,11 @@ import { import { MatAutocomplete, MatAutocompleteModule, + MatAutocompleteOrigin, MatOption } from '@angular/material/autocomplete'; import { + MAT_FORM_FIELD, MatFormFieldControl, MatFormFieldModule } from '@angular/material/form-field'; @@ -39,7 +46,8 @@ import { AbstractMatFormField } from '../shared/abstract-mat-form-field'; changeDetection: ChangeDetectionStrategy.OnPush, host: { '[attr.aria-describedBy]': 'describedBy', - '[id]': 'id' + '[id]': 'id', + class: 'align-items-center d-flex' }, imports: [ FormsModule, @@ -56,7 +64,6 @@ import { AbstractMatFormField } from '../shared/abstract-mat-form-field'; ], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-currency-selector', - styleUrls: ['./currency-selector.component.scss'], templateUrl: 'currency-selector.component.html' }) export class GfCurrencySelectorComponent @@ -72,6 +79,7 @@ export class GfCurrencySelectorComponent public readonly formControlName = input.required(); private readonly destroyRef = inject(DestroyRef); + private readonly formField = inject(MAT_FORM_FIELD); private readonly input = viewChild.required(MatInput); public constructor( @@ -86,8 +94,20 @@ export class GfCurrencySelectorComponent this.controlType = 'currency-selector'; } + public get autocompleteOrigin(): MatAutocompleteOrigin { + return { elementRef: this.formField.getConnectedOverlayOrigin() }; + } + + public get emojiFlagOfSelectedCurrency() { + const selectedCurrency = this.currencies().find((currency) => { + return currency === this.control.value; + }); + + return this.getEmojiFlagFromCurrency(selectedCurrency); + } + public override get empty() { - return this.input().empty; + return !this.control.value; } public override set value(value: string | null) { @@ -99,6 +119,10 @@ export class GfCurrencySelectorComponent this.input().focus(); } + public getEmojiFlagFromCurrency(aCurrency = '') { + return getEmojiFlag(getCountryCodeFromCurrency(aCurrency)); + } + public ngOnInit() { if (this.disabled) { this.control.disable(); diff --git a/libs/ui/src/lib/entity-logo/entity-logo.component.html b/libs/ui/src/lib/entity-logo/entity-logo.component.html index d8aeba136..0a7ecbcd2 100644 --- a/libs/ui/src/lib/entity-logo/entity-logo.component.html +++ b/libs/ui/src/lib/entity-logo/entity-logo.component.html @@ -1,8 +1,11 @@ -@if (src) { +@if (src && !hasError) { +} @else if (hasPlaceholder) { + } diff --git a/libs/ui/src/lib/entity-logo/entity-logo.component.scss b/libs/ui/src/lib/entity-logo/entity-logo.component.scss index 23bc7a487..051199346 100644 --- a/libs/ui/src/lib/entity-logo/entity-logo.component.scss +++ b/libs/ui/src/lib/entity-logo/entity-logo.component.scss @@ -2,7 +2,7 @@ align-items: center; display: flex; - img { + .logo { border-radius: 0.2rem; height: 0.8rem; width: 0.8rem; @@ -11,5 +11,15 @@ height: 1.4rem; width: 1.4rem; } + + &.placeholder { + border: 1px solid rgba(var(--dark-dividers)); + } + } +} + +:host-context(.theme-dark) { + .placeholder { + border-color: rgba(var(--light-dividers)); } } diff --git a/libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts b/libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts index 45a996294..786d54cbf 100644 --- a/libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts +++ b/libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts @@ -44,3 +44,19 @@ export const LogoByUrl: Story = { url: 'https://ghostfol.io' } }; + +export const Placeholder: Story = { + args: { + hasPlaceholder: true, + size: 'large' + } +}; + +export const PlaceholderOnError: Story = { + args: { + hasPlaceholder: true, + size: 'large', + tooltip: 'Unknown', + url: 'https://unknown.ghostfol.io' + } +}; diff --git a/libs/ui/src/lib/entity-logo/entity-logo.component.ts b/libs/ui/src/lib/entity-logo/entity-logo.component.ts index ba7d64ae0..ef8e72105 100644 --- a/libs/ui/src/lib/entity-logo/entity-logo.component.ts +++ b/libs/ui/src/lib/entity-logo/entity-logo.component.ts @@ -18,18 +18,22 @@ import { DataSource } from '@prisma/client'; }) export class GfEntityLogoComponent implements OnChanges { @Input() dataSource: DataSource; + @Input() hasPlaceholder = false; @Input() size: 'large'; @Input() symbol: string; @Input() tooltip: string; @Input() url: string; - public src: string; + public hasError = false; + public src?: string; public constructor( private readonly imageSourceService: EntityLogoImageSourceService ) {} public ngOnChanges() { + this.hasError = false; + if (this.dataSource && this.symbol) { this.src = this.imageSourceService.getLogoUrlByAssetProfileIdentifier({ dataSource: this.dataSource, @@ -37,6 +41,12 @@ export class GfEntityLogoComponent implements OnChanges { }); } else if (this.url) { this.src = this.imageSourceService.getLogoUrlByUrl(this.url); + } else { + this.src = undefined; } } + + public onError() { + this.hasError = true; + } } diff --git a/libs/ui/src/lib/mocks/entity-logo-image-source.service.mock.ts b/libs/ui/src/lib/mocks/entity-logo-image-source.service.mock.ts index 3f4dbbef7..766a4178b 100644 --- a/libs/ui/src/lib/mocks/entity-logo-image-source.service.mock.ts +++ b/libs/ui/src/lib/mocks/entity-logo-image-source.service.mock.ts @@ -2,6 +2,9 @@ import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { DataSource } from '@prisma/client'; +// Resolves like a logo of an unknown entity, but fails to load +const UNAVAILABLE_LOGO_URL = 'data:image/png;base64,unavailable'; + export class EntityLogoImageSourceServiceMock { public getLogoUrlByAssetProfileIdentifier({ dataSource, @@ -11,7 +14,7 @@ export class EntityLogoImageSourceServiceMock { return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAJa0lEQVR4nM2bW2wU1xnHf3vx2t61za5nL/bGKwx2jQnwUh5Q3bqyHYhzIVLUFoIaSKtGSrkkNaQJqlQSKaHQFygQJamUSomaRKpSlYekqSF1HGix6EOktIldOQs0OCEs3ssw48Ve8F77MGuwza4ve2Y3/KR92Ln8z/d9OnPmnG/OZ/jss2FKRA3QCiwDPEAtUAEkgWvAGKAAF4HPgWgpjDIXUdsF3AN0Zn/NVmuFoazMgtlswmw2YTAYyGQgnU6TSqVIpdIkEnFisRsZ4AJwGjgFfAiEi2GkQeceUAn8CPgJ0GG315hsNis2m5XycsuihCYn40xMxJiYiKGq0RRaMP4I/AW4rpfBegXAAfQAu+z2JU6HowabzYbBoIc0ZDIwMTGBokRR1bEI8ApwDO2REUI0AGbgKeB5t1uyS1ItZrNJ1KY5SSZTyPJVQiFZBfYDL6GNIwUhEoDvAr+XJMcaSXIsuouLEo/HiUQUZFkZBHYCA4XoGAu4xwS8APzD5/Ou8Xo9JXcewGKx4PV68Pm8a9DGhxezti2KxfYAF/COJDk63W5n0bv7QkkmU4RCEWRZOQU8wiLeGIvpAY3AgNvt7PR6PXeM8wBmswmv14Pb7exEexQaF3rvQgOwCjhbV+dq8XicBZhYGjweJ3V1rhbgLJrN87KQADQBJ71eT73LJYnYVxJcLgmv11MPnASa57t+vgC4gJP19e4GSXLoYV9JkCQH9fXuBuAEmg95mSsAZcBxt9vZ7HTW6mlfXsbHJzh27CU6Ojppbm7h4sWLBWs5nbW43c5m4DiaLzmZay2wX5Ic7aV65s+dO8fu3U8zNDQEgM1mo7q6WkjT43GSSqXaZVnZD/wq1zX5esD3gGfr6ubsPbpx/vx5tm597KbzAK2tK6itFe95WR+eRfPpNnIFwAy86vN5jUZjIfOkxTE5Ocnu3U8zOjo64/hDD21Ej/aNRiM+n9cIvEqOHp+rhR5Jcqyx22uEG18IfX19DA4OzjhWX1/Pww8/rFsbdnsNkuRYA/xi9rnZAagB9pVq0AN4//3eGf+rq6s5cuSwLt1/Ok6nA+A5NB9vMjsAO10uyW6x5B00dSWRSPDpp/+5+b+lpYW3336LtrY23duyWCy4XJIdbeF0k+nPRCWwR5Lsujeej3Q6zbJly1m6dCnd3d1s27YVs7l4SSpJshMOy3uAo8ANmLkY2mK31/zJ5/Pq1qAsy/T1fcjg4CCKomK1VrJy5Uo6Ojpoalp+2/VDQ0P09X2I33+O8fFrlJdX4PXW09bWRldXJ+Xl5cI2ffVVgLGx6BbgHZgZgL81NjY8UF1dJdxIMpnktdf+wOuvv0EoFLrtfEVFBV1dXWzc+AB33dXA4OAg7733Vz7++GMymUxOzdbWVvbufYb169cL2Xbt2jgjI1/3Ag/CrQC4gMDq1SvMBsE81uTkJDt3PklfX5+QTj4OHjzA1q2PFnx/JpNhaMifBLxAeGoQvMdurxF2PpVK0dOzp2jOA+zb9xx+v7/g+w0GA3Z7jRktY33zLdBls1mFjXvrrbfp7e2d/8ICMRgMHDjwG5qamoR0sr52wa23QGdVlVgAotEoL7/8ipDGfDz//HM8+uiPhXWyAegArQdYgeUWi1he78SJkzkHPL3o7r6Xxx//mS5a2RxmE1BpBJorKsqFJ939/f2iEnkxmUw89dSTumpmff6WEWjVI6s7MvKlsEY+li9fzqpVC8pwLZiszyuMQH1ZmfjsKxodE9bIR3NzEyaTvknY7Iyz3gg49BCfnIwLa+SjslL8DTWbrM8OI1Au+v4HdFm750NRruquaTQaACqMQCrP7HNR2Gw2cZE8DA9/TiwW01Uz63PSCFxLp9PCgnb7EmGNfIyOjnL69GldNbM+jxuBaDqdEhZctmyZsMZcHDlyTNdekPU5agTkZFI8AOvWrRPWmAu/309Pz24SiYQuelmfw0bgcz1G8La271BWVtxM0gcf/J0nntjO5cuXhbWyPp8zAhdu3JgUHgQaGxtZu/bbwobNR39/P5s2PSKsk/X5vBGIAV/E4+K9YMuWLcIaCyGZLHhDCKBtrgC+AK5PvbxPjY+LDzD33deNz+cT1pmPzZs3Cd2f9fUU3MoHfDQxIR4Aq9XKrl07hHXmora2lm3btgppZH2dEYB+VY0m8+XjFsPmzZtZvXq1sE4+duzYjsfjKfj+TCaDqkaTQD/cCkAY6NPjMTCbzezf/0JR0tt3372Sxx7bJqSR9bEPCMHMDyNvqqo+K7q1a9eyd++zumhNUVVl4/DhQ1RWVgrpZH18c+r/9AC8q6rRsF4Tje3bfy78rE5hNps5evSocE4gkUigqtEQ8O5N7WnnrwO/k2X1t3p9Fn/xxReIxa5z/PjxGcdtNhvr199De3s7DQ0NxONx/H4/J06c4JNP/j3j2qoqGwcPHuTeezcI2yPLKsARpm21nb1Nrgb4csWKJl2/D/b29nLmzACJRIKWlhY2bFifc+2QyWQYGBjg7Nl/oSgqdXUeNm58kObmebf6zEs8nsDv/58KLGXaTvRc+wR/KUmOQ15v4SPtnUggEESWlWeAw9OP58piHJNlZUhVS7JdvySoanRqS+2x2edyBSAJ7Lh0KZDWI0/wTZNOp7l0KZBG+yx+2xw6Xx5rADg0OlqUGoWSEgyGAQ6RZzP1XIm8fbKsnAkGI8WwqyQEgxEiEWUAbWdITuYKQAL4YSgUuRCJ6J+ULDaRyFVCocgF4AdA3qXufKncMHD/lSuhr2VZuDijZMiywpUrocvA/cyzc3whuewLwH2BQPBKOCzrYV9RCYdlAoHgFaAbzfY5WWgy/79A2+ho+NydPCYEgxFGR8MXgDY0m+dlMV8zRoD2UChyKhAIokciVS+SyRSBQJBQKHIKrZRnZKH3LvZzTgjYIMvK/uHh8+k7YbKkqlGGh8+ns/uBN5Bd5i4UkaKpduBVSXKsdjprKdXewini8QSRyFVkWRlCm+ScKURHj7K5HuDXLpfkkCQHenxpnotEIoksK4TDsgIcQJvefiNlc9OpAZ4EehyOJW6HYwlWq1XXwslYLIaijKEoYyE0p19Gh/riYpTObgJ+Cnx/qnS2qsrKYrfgxONxxsdnlM7+E6109s/cgaWzufCg7cTqAjqMRmNzRUU5ZWVllJWZMJnyFU8nuHFjknQ6PVU8/VH2FyyGkcUMwGxqgJVoJW11gARYuL18fgQYpkTl8/8HqhBYlUKrXOwAAAAASUVORK5CYII='; } - return ''; + return UNAVAILABLE_LOGO_URL; } public getLogoUrlByUrl(url: string) { @@ -19,6 +22,6 @@ export class EntityLogoImageSourceServiceMock { return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAP1BMVEU2z8v////x/Pspzcn0/Px73drR8/KC3tz6/v6e5eOl5+WM4d7n+PhI08/i9/eu6edV1dJk2NVx2teV4+G87evvttLSAAABDElEQVRYhe2V3Y6EIAyFrQXlTwHd93/WpeBsdiKbtF7tJJwbCKFfDlKO0zQ0NPQJwqrn1ZO2zlk9PWPgusClRcsJmHb4pWUTItDDu4zMBJ5w0yog4HGvB0gCgOoBZrYFdL16AMsmmD5AcQ3ofj3AwbOAX38BIhMw02ZjtQ+tLnht66mCAGA2eka1G3eabUZwD/PLbeuHenKMBODVV0Dru/TTQLgKAc1BvQ/9yAEkOnlon66oehEBIHp7dbSyPoIc0NEADMDnAa4wAvVK+CADRGx/VpNSi+iF3jMTUH4rJQ0aISPmVk+JoJiZeJw1QnaqL2OmWKSFkxnrZWsbXK4TzO5tmS+8TYaGhv6xvgEEfAgHGc7HRgAAAABJRU5ErkJggg=='; } - return ''; + return UNAVAILABLE_LOGO_URL; } } diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html index cda9cab3c..6c8902112 100644 --- a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html @@ -7,13 +7,12 @@ @for (account of accounts(); track account.id) {
- @if (account.platform?.url) { - - } + {{ account.name }}
diff --git a/package-lock.json b/package-lock.json index ce97c403b..acdc26aea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.43.0", + "version": "3.44.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.43.0", + "version": "3.44.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index b13aeab4c..ef23f55d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.43.0", + "version": "3.44.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", diff --git a/prisma/migrations/20260805120000_removed_balance_from_account/migration.sql b/prisma/migrations/20260805120000_removed_balance_from_account/migration.sql new file mode 100644 index 000000000..2371cb748 --- /dev/null +++ b/prisma/migrations/20260805120000_removed_balance_from_account/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Account" DROP COLUMN "balance"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 77faccd49..4451be4e2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,7 +27,6 @@ model Access { model Account { activities Order[] - balance Float @default(0) balances AccountBalance[] comment String? createdAt DateTime @default(now()) diff --git a/test/import/not-ok/invalid-platform.json b/test/import/not-ok/invalid-platform.json index 69a8e29ca..d280228b8 100644 --- a/test/import/not-ok/invalid-platform.json +++ b/test/import/not-ok/invalid-platform.json @@ -5,7 +5,6 @@ }, "accounts": [ { - "balance": 0, "balances": [], "currency": "USD", "id": "e62be662-a2c8-4cff-8b79-dc0a46576659", diff --git a/test/import/ok/500-activities.json b/test/import/ok/500-activities.json index 03aabca33..3c74d8517 100644 --- a/test/import/ok/500-activities.json +++ b/test/import/ok/500-activities.json @@ -5,7 +5,6 @@ }, "accounts": [ { - "balance": 2000, "currency": "USD", "id": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0", "name": "My Online Trading Account", diff --git a/test/import/ok/derived-currency.json b/test/import/ok/derived-currency.json index 4b7aa46c3..100ab6739 100644 --- a/test/import/ok/derived-currency.json +++ b/test/import/ok/derived-currency.json @@ -5,7 +5,6 @@ }, "accounts": [ { - "balance": 2000, "currency": "USD", "id": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0", "name": "My Online Trading Account", diff --git a/test/import/ok/sample.json b/test/import/ok/sample.json index feca8a379..3c75c492b 100644 --- a/test/import/ok/sample.json +++ b/test/import/ok/sample.json @@ -5,7 +5,6 @@ }, "accounts": [ { - "balance": 2000, "balances": [ { "date": "2024-12-31T00:00:00.000Z",