Browse Source

Merge branch 'main' into task/add-platform-logo-to-account-selectors-in-transfer-cash-balance-dialog

pull/7554/head
Thomas Kaul 3 weeks ago
committed by GitHub
parent
commit
e482ed8ae1
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 16
      CHANGELOG.md
  2. 48
      apps/api/src/app/account/account.controller.ts
  3. 64
      apps/api/src/app/account/account.service.ts
  4. 4
      apps/api/src/app/account/interfaces/cash-details.interface.ts
  5. 6
      apps/api/src/app/auth/auth.module.ts
  6. 69
      apps/api/src/app/auth/web-auth.service.ts
  7. 4
      apps/api/src/app/export/export.service.ts
  8. 12
      apps/api/src/app/import/import.service.ts
  9. 18
      apps/api/src/app/portfolio/portfolio.controller.ts
  10. 7
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  11. 9
      apps/api/src/app/portfolio/portfolio.service.ts
  12. 2
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  13. 10
      apps/client/src/app/app.component.ts
  14. 11
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  15. 4
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  16. 2
      apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
  17. 5
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  18. 4
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  19. 2
      apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts
  20. 1
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  21. 9
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  22. 24
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  23. 21
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  24. 8
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts
  25. 23
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  26. 34
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  27. 12
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  28. 2
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  29. 2
      libs/common/src/lib/config.ts
  30. 7
      libs/common/src/lib/dtos/create-account.dto.ts
  31. 7
      libs/common/src/lib/dtos/update-account.dto.ts
  32. 25
      libs/common/src/lib/helper.spec.ts
  33. 11
      libs/common/src/lib/helper.ts
  34. 1
      libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts
  35. 12
      libs/common/src/lib/permissions.ts
  36. 5
      libs/common/src/lib/types/account-with-balance.type.ts
  37. 6
      libs/common/src/lib/types/account-with-value.type.ts
  38. 2
      libs/common/src/lib/types/index.ts
  39. 20
      libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts
  40. 14
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  41. 5
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  42. 10
      libs/ui/src/lib/currency-selector/currency-selector.component.html
  43. 3
      libs/ui/src/lib/currency-selector/currency-selector.component.scss
  44. 99
      libs/ui/src/lib/currency-selector/currency-selector.component.stories.ts
  45. 30
      libs/ui/src/lib/currency-selector/currency-selector.component.ts
  46. 7
      libs/ui/src/lib/shared/abstract-mat-form-field.ts
  47. 2
      prisma/migrations/20260805120000_removed_balance_from_account/migration.sql
  48. 1
      prisma/schema.prisma
  49. 1
      test/import/not-ok/invalid-platform.json
  50. 1
      test/import/ok/500-activities.json
  51. 1
      test/import/ok/derived-currency.json
  52. 1
      test/import/ok/sample.json

16
CHANGELOG.md

@ -9,8 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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 - 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 - 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 ## 3.43.0 - 2026-08-06

48
apps/api/src/app/account/account.controller.ts

@ -156,32 +156,34 @@ export class AccountController {
public async createAccount( public async createAccount(
@Body() data: CreateAccountDto @Body() data: CreateAccountDto
): Promise<AccountModel> { ): Promise<AccountModel> {
const { tags: tagIds, ...accountData } = data; const { balance, tags: tagIds, ...accountData } = data;
if (accountData.platformId) { if (accountData.platformId) {
const platformId = accountData.platformId; const platformId = accountData.platformId;
delete accountData.platformId; delete accountData.platformId;
return this.accountService.createAccount( return this.accountService.createAccount({
{ balance,
tagIds,
data: {
...accountData, ...accountData,
platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id, userId: this.request.user.id
tagIds });
);
} else { } else {
delete accountData.platformId; delete accountData.platformId;
return this.accountService.createAccount( return this.accountService.createAccount({
{ balance,
tagIds,
data: {
...accountData, ...accountData,
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id, userId: this.request.user.id
tagIds });
);
} }
} }
@ -257,35 +259,35 @@ export class AccountController {
); );
} }
const { tags: tagIds, ...accountData } = data; const { balance, tags: tagIds, ...accountData } = data;
if (accountData.platformId) { if (accountData.platformId) {
const platformId = accountData.platformId; const platformId = accountData.platformId;
delete accountData.platformId; delete accountData.platformId;
return this.accountService.updateAccount( return this.accountService.updateAccount({
{ balance,
tagIds,
data: { data: {
...accountData, ...accountData,
platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
userId: this.request.user.id,
where: { where: {
id_userId: { id_userId: {
id, id,
userId: this.request.user.id userId: this.request.user.id
} }
} }
}, });
this.request.user.id,
tagIds
);
} else { } else {
// platformId is null, remove it // platformId is null, remove it
delete accountData.platformId; delete accountData.platformId;
return this.accountService.updateAccount( return this.accountService.updateAccount({
{ balance,
tagIds,
data: { data: {
...accountData, ...accountData,
platform: originalAccount.platformId platform: originalAccount.platformId
@ -293,16 +295,14 @@ export class AccountController {
: undefined, : undefined,
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
userId: this.request.user.id,
where: { where: {
id_userId: { id_userId: {
id, id,
userId: this.request.user.id userId: this.request.user.id
} }
} }
}, });
this.request.user.id,
tagIds
);
} }
} }
} }

64
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 { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { AccountWithBalance } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
@ -24,7 +25,7 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { endOfToday, format } from 'date-fns'; import { endOfToday, format } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy, isNil } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@ -40,7 +41,7 @@ export class AccountService {
public async account({ public async account({
id_userId id_userId
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> { }: Prisma.AccountWhereUniqueInput): Promise<AccountWithBalance | null> {
const account = await this.prismaService.account.findUnique({ const account = await this.prismaService.account.findUnique({
include: { include: {
balances: { balances: {
@ -87,7 +88,7 @@ export class AccountService {
where?: Prisma.AccountWhereInput; where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByWithRelationInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(Account & { (AccountWithBalance & {
activities?: (Order & { SymbolProfile?: SymbolProfile })[]; activities?: (Order & { SymbolProfile?: SymbolProfile })[];
balances?: AccountBalance[]; balances?: AccountBalance[];
platform?: Platform; platform?: Platform;
@ -160,12 +161,18 @@ export class AccountService {
}); });
} }
public async createAccount( public async createAccount({
data: Prisma.AccountCreateInput, balance,
aUserId: string, data,
tagIds?: string[] tagIds,
): Promise<Account> { userId
await this.tagService.validateTagIds({ tagIds, userId: aUserId }); }: {
balance?: number;
data: Prisma.AccountCreateInput;
tagIds?: string[];
userId: string;
}): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId });
const account = await this.prismaService.account.create({ const account = await this.prismaService.account.create({
data: { data: {
@ -182,12 +189,14 @@ export class AccountService {
} }
}); });
if (!isNil(balance)) {
await this.accountBalanceService.createOrUpdateAccountBalance({ await this.accountBalanceService.createOrUpdateAccountBalance({
balance,
userId,
accountId: account.id, accountId: account.id,
balance: data.balance, date: format(new Date(), DATE_FORMAT)
date: format(new Date(), DATE_FORMAT),
userId: aUserId
}); });
}
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
@ -216,7 +225,7 @@ export class AccountService {
return account; return account;
} }
public async getAccounts(aUserId: string): Promise<Account[]> { public async getAccounts(aUserId: string): Promise<AccountWithBalance[]> {
const accounts = await this.accounts({ const accounts = await this.accounts({
include: { include: {
activities: true, activities: true,
@ -295,17 +304,20 @@ export class AccountService {
}; };
} }
public async updateAccount( public async updateAccount({
params: { balance,
data,
tagIds,
userId,
where
}: {
balance?: number;
data: Prisma.AccountUpdateInput; data: Prisma.AccountUpdateInput;
tagIds?: string[];
userId: string;
where: Prisma.AccountWhereUniqueInput; where: Prisma.AccountWhereUniqueInput;
}, }): Promise<Account> {
aUserId: string, await this.tagService.validateTagIds({ tagIds, userId });
tagIds?: string[]
): Promise<Account> {
const { data, where } = params;
await this.tagService.validateTagIds({ tagIds, userId: aUserId });
const account = await this.prismaService.account.update({ const account = await this.prismaService.account.update({
data: { data: {
@ -324,12 +336,14 @@ export class AccountService {
where where
}); });
if (!isNil(balance)) {
await this.accountBalanceService.createOrUpdateAccountBalance({ await this.accountBalanceService.createOrUpdateAccountBalance({
balance,
userId,
accountId: account.id, accountId: account.id,
balance: data.balance as number, date: format(new Date(), DATE_FORMAT)
date: format(new Date(), DATE_FORMAT),
userId: aUserId
}); });
}
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),

4
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 { export interface CashDetails {
accounts: Account[]; accounts: AccountWithBalance[];
balanceInBaseCurrency: number; balanceInBaseCurrency: number;
} }

6
apps/api/src/app/auth/auth.module.ts

@ -1,14 +1,17 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.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 { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; 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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.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 { Logger, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -24,14 +27,17 @@ import { OidcStrategy } from './oidc.strategy';
@Module({ @Module({
controllers: [AuthController], controllers: [AuthController],
imports: [ imports: [
ApiModule,
ConfigurationModule, ConfigurationModule,
FetchModule, FetchModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
}), }),
PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule,
SubscriptionModule, SubscriptionModule,
UserModule UserModule
], ],

69
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 { 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 { 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 { 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 { AuthDeviceDto } from '@ghostfolio/common/dtos';
import { import {
AssertionCredentialJSON, AssertionCredentialJSON,
@ -29,6 +38,7 @@ import {
VerifyRegistrationResponseOpts VerifyRegistrationResponseOpts
} from '@simplewebauthn/server'; } from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'; import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import { isPast } from 'date-fns';
import ms from 'ms'; import ms from 'ms';
@Injectable() @Injectable()
@ -36,9 +46,12 @@ export class WebAuthService {
private readonly logger = new Logger(WebAuthService.name); private readonly logger = new Logger(WebAuthService.name);
public constructor( public constructor(
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly deviceService: AuthDeviceService, private readonly deviceService: AuthDeviceService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly portfolioSnapshotService: PortfolioSnapshotService,
private readonly redisCacheService: RedisCacheService,
private readonly userService: UserService, private readonly userService: UserService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -155,6 +168,9 @@ export class WebAuthService {
throw new Error('Device not found'); throw new Error('Device not found');
} }
// Compute in the background during the biometric authentication
void this.warmUpPortfolioSnapshot({ userId: device.userId });
const opts: GenerateAuthenticationOptionsOpts = { const opts: GenerateAuthenticationOptionsOpts = {
allowCredentials: [], allowCredentials: [],
rpID: this.rpID, rpID: this.rpID,
@ -233,4 +249,57 @@ export class WebAuthService {
throw new Error(); 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
);
}
}
} }

4
apps/api/src/app/export/export.service.ts

@ -102,7 +102,6 @@ export class ExportService {
}) })
.map( .map(
({ ({
balance,
balances, balances,
comment, comment,
currency, currency,
@ -111,13 +110,12 @@ export class ExportService {
platform, platform,
platformId, platformId,
tags tags
}) => { }): ExportResponse['accounts'][number] => {
if (platformId) { if (platformId) {
platformsMap[platformId] = platform; platformsMap[platformId] = platform;
} }
return { return {
balance,
balances: balances.map(({ date, value }) => { balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value }; return { date: date.toISOString(), value };
}), }),

12
apps/api/src/app/import/import.service.ts

@ -355,6 +355,7 @@ export class ImportService {
// If there is no account or if the account belongs to a different user then create a new account // If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== user.id) { if (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account = omit(accountWithBalances, [ const account = omit(accountWithBalances, [
'balance',
'balances', 'balances',
'isExcluded', 'isExcluded',
'tags' 'tags'
@ -408,11 +409,12 @@ export class ImportService {
}; };
} }
const newAccount = await this.accountService.createAccount( const newAccount = await this.accountService.createAccount({
accountObject, tagIds,
user.id, balance: accountWithBalances.balance,
tagIds data: accountObject,
); userId: user.id
});
// Store the new to old account ID mappings for updating activities // Store the new to old account ID mappings for updating activities
if (accountWithSameId && oldAccountId) { if (accountWithSameId && oldAccountId) {

18
apps/api/src/app/portfolio/portfolio.controller.ts

@ -136,7 +136,7 @@ export class PortfolioController {
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -180,7 +180,7 @@ export class PortfolioController {
hasDetails === false || hasDetails === false ||
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -374,7 +374,7 @@ export class PortfolioController {
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -491,19 +491,19 @@ export class PortfolioController {
filterByTags: tags filterByTags: tags
}); });
let { investments, streaks } = await this.portfolioService.getInvestments({ let { investments, savingsRate, streaks } =
await this.portfolioService.getInvestments({
filters, filters,
groupBy, groupBy,
impersonationId, impersonationId,
dateRange: range, dateRange: range,
savingsRate: this.request.user?.settings?.settings.savingsRate,
userId: this.request.user.id userId: this.request.user.id
}); });
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -521,6 +521,8 @@ export class PortfolioController {
'currentStreak', 'currentStreak',
'longestStreak' 'longestStreak'
]); ]);
savingsRate = null;
} }
if ( if (
@ -537,7 +539,7 @@ export class PortfolioController {
]); ]);
} }
return { investments, streaks }; return { investments, savingsRate, streaks };
} }
@Get('performance') @Get('performance')
@ -578,7 +580,7 @@ export class PortfolioController {
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) || isRestrictedView(this.request.user) ||
this.request.user.settings.settings.viewMode === 'ZEN' this.request.user.settings.settings.viewMode === 'ZEN'

7
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -16,8 +16,9 @@ import {
AssetProfileIdentifier, AssetProfileIdentifier,
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } 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 { Big } from 'big.js';
import { randomUUID } from 'node:crypto'; 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 () => { it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => {
const accountId = randomUUID(); const accountId = randomUUID();
const cashAccount: Account = { const cashAccount: AccountWithBalance = {
balance: 2000, balance: 2000,
comment: null, comment: null,
createdAt: parseDate('2024-01-01'), createdAt: parseDate('2024-01-01'),
@ -444,7 +445,7 @@ describe('PortfolioService', () => {
beforeEach(() => { beforeEach(() => {
jest jest
.spyOn(accountService, 'getAccounts') .spyOn(accountService, 'getAccounts')
.mockResolvedValue([account] as unknown as Account[]); .mockResolvedValue([account] as unknown as AccountWithBalance[]);
jest jest
.spyOn(exchangeRateDataService, 'toCurrency') .spyOn(exchangeRateDataService, 'toCurrency')

9
apps/api/src/app/portfolio/portfolio.service.ts

@ -64,6 +64,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models'; import { TimelinePosition } from '@ghostfolio/common/models';
import { import {
AccountWithBalance,
AccountWithValue, AccountWithValue,
DateRange, DateRange,
GroupBy, GroupBy,
@ -75,7 +76,6 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { import {
Account,
Type as ActivityType, Type as ActivityType,
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -413,19 +413,18 @@ export class PortfolioService {
filters, filters,
groupBy, groupBy,
impersonationId, impersonationId,
savingsRate,
userId userId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
groupBy?: GroupBy; groupBy?: GroupBy;
impersonationId: string; impersonationId: string;
savingsRate: number;
userId: string; userId: string;
}): Promise<PortfolioInvestmentsResponse> { }): Promise<PortfolioInvestmentsResponse> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate;
const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
@ -438,6 +437,7 @@ export class PortfolioService {
if (activities.length === 0) { if (activities.length === 0) {
return { return {
savingsRate,
investments: [], investments: [],
streaks: { currentStreak: 0, longestStreak: 0 } streaks: { currentStreak: 0, longestStreak: 0 }
}; };
@ -484,6 +484,7 @@ export class PortfolioService {
return { return {
investments, investments,
savingsRate,
streaks streaks
}; };
} }
@ -2142,7 +2143,7 @@ export class PortfolioService {
const accounts: PortfolioDetails['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
const platforms: PortfolioDetails['platforms'] = {}; const platforms: PortfolioDetails['platforms'] = {};
let currentAccounts: (Account & { let currentAccounts: (AccountWithBalance & {
Order?: Order[]; Order?: Order[];
platform?: Platform; platform?: Platform;
tags?: Tag[]; tags?: Tag[];

2
apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts

@ -38,7 +38,7 @@ export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user accesses: user?.accessesGet
}) || }) ||
isRestrictedView(user) isRestrictedView(user)
) { ) {

10
apps/client/src/app/app.component.ts

@ -57,12 +57,12 @@ export class GfAppComponent implements OnInit {
public currentRoute: string; public currentRoute: string;
public currentSubRoute: string; public currentSubRoute: string;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean;
public hasInfoMessage: boolean; public hasInfoMessage: boolean;
public hasPermissionToChangeDateRange: boolean; public hasPermissionToChangeDateRange: boolean;
public hasPermissionToChangeFilters: boolean; public hasPermissionToChangeFilters: boolean;
public hasPromotion = false; public hasPromotion = false;
public hasTabs = false; public hasTabs = false;
public impersonationId: string | null;
public info: InfoItem; public info: InfoItem;
public pageTitle: string; public pageTitle: string;
public routerLinkRegister = publicRoutes.register.routerLink; public routerLinkRegister = publicRoutes.register.routerLink;
@ -116,7 +116,7 @@ export class GfAppComponent implements OnInit {
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.impersonationId = impersonationId;
}); });
this.router.events this.router.events
@ -291,13 +291,12 @@ export class GfAppComponent implements OnInit {
baseCurrency: this.user?.settings?.baseCurrency, baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme, colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType, deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToAccessAdminControl: hasPermission( hasPermissionToAccessAdminControl: hasPermission(
this.user?.permissions, this.user?.permissions,
permissions.accessAdminControl permissions.accessAdminControl
), ),
hasPermissionToCreateActivity: hasPermissionToCreateActivity:
!this.hasImpersonationId && !this.impersonationId &&
hasPermission( hasPermission(
this.user?.permissions, this.user?.permissions,
permissions.createActivity permissions.createActivity
@ -308,12 +307,13 @@ export class GfAppComponent implements OnInit {
permissions.reportDataGlitch permissions.reportDataGlitch
), ),
hasPermissionToUpdateActivity: hasPermissionToUpdateActivity:
!this.hasImpersonationId && !this.impersonationId &&
hasPermission( hasPermission(
this.user?.permissions, this.user?.permissions,
permissions.updateActivity permissions.updateActivity
) && ) &&
!this.user?.settings?.isRestrictedView, !this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId,
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
height: this.deviceType === 'mobile' ? '98vh' : '80vh', height: this.deviceType === 'mobile' ? '98vh' : '80vh',

11
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -14,7 +14,11 @@ import {
PortfolioPosition, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } 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 { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
@ -225,7 +229,10 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected showValuesInPercentage() { protected showValuesInPercentage() {
return ( return (
this.data.hasImpersonationId || this.user?.settings?.isRestrictedView hasReadRestrictedAccessPermission({
accesses: this.user?.access,
impersonationId: this.data.impersonationId
}) || this.user?.settings?.isRestrictedView
); );
} }

4
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -158,8 +158,8 @@
[pageSize]="pageSize" [pageSize]="pageSize"
[showAccountColumn]="false" [showAccountColumn]="false"
[showActions]=" [showActions]="
!data.hasImpersonationId &&
data.hasPermissionToCreateActivity && data.hasPermissionToCreateActivity &&
!data.impersonationId &&
user?.settings?.isExperimentalFeatures && user?.settings?.isExperimentalFeatures &&
!user?.settings?.isRestrictedView !user?.settings?.isRestrictedView
" "
@ -183,8 +183,8 @@
[currentBalance]="balance" [currentBalance]="balance"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]=" [showActions]="
!data.hasImpersonationId &&
hasPermissionToDeleteAccountBalance && hasPermissionToDeleteAccountBalance &&
!data.impersonationId &&
!user.settings.isRestrictedView !user.settings.isRestrictedView
" "
(accountBalanceCreated)="onAddAccountBalance($event)" (accountBalanceCreated)="onAddAccountBalance($event)"

2
apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts

@ -1,8 +1,8 @@
export interface AccountDetailDialogParams { export interface AccountDetailDialogParams {
accountId: string; accountId: string;
deviceType: string; deviceType: string;
hasImpersonationId: boolean;
hasPermissionToCreateActivity: boolean; hasPermissionToCreateActivity: boolean;
impersonationId: string | null;
} }
export interface AccountDetailDialogResult { export interface AccountDetailDialogResult {

5
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -22,6 +22,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { AccountWithValue } from '@ghostfolio/common/types';
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table'; import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits'; import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits';
@ -65,7 +66,7 @@ import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { NavigationStart, Router, RouterModule } from '@angular/router'; import { NavigationStart, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { Account, MarketData, Tag } from '@prisma/client'; import { MarketData, Tag } from '@prisma/client';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
@ -117,7 +118,7 @@ import {
templateUrl: 'holding-detail-dialog.html' templateUrl: 'holding-detail-dialog.html'
}) })
export class GfHoldingDetailDialogComponent implements OnInit { export class GfHoldingDetailDialogComponent implements OnInit {
protected accounts: Account[]; protected accounts: AccountWithValue[];
protected activitiesCount: number; protected activitiesCount: number;
protected assetClass: string; protected assetClass: string;
protected assetProfile: Pick< protected assetProfile: Pick<

4
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -382,7 +382,7 @@
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="false" [hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]=" [hasPermissionToExportActivities]="
!data.hasImpersonationId && !user?.settings?.isRestrictedView !data.impersonationId && !user?.settings?.isRestrictedView
" "
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
@ -390,8 +390,8 @@
[pageIndex]="pageIndex" [pageIndex]="pageIndex"
[pageSize]="pageSize" [pageSize]="pageSize"
[showActions]=" [showActions]="
!data.hasImpersonationId &&
data.hasPermissionToCreateActivity && data.hasPermissionToCreateActivity &&
!data.impersonationId &&
user?.settings?.isExperimentalFeatures && user?.settings?.isExperimentalFeatures &&
!user?.settings?.isRestrictedView !user?.settings?.isRestrictedView
" "

2
apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts

@ -7,11 +7,11 @@ export interface HoldingDetailDialogParams {
colorScheme: ColorScheme; colorScheme: ColorScheme;
dataSource: DataSource; dataSource: DataSource;
deviceType: string; deviceType: string;
hasImpersonationId: boolean;
hasPermissionToAccessAdminControl: boolean; hasPermissionToAccessAdminControl: boolean;
hasPermissionToCreateActivity: boolean; hasPermissionToCreateActivity: boolean;
hasPermissionToReportDataGlitch: boolean; hasPermissionToReportDataGlitch: boolean;
hasPermissionToUpdateActivity: boolean; hasPermissionToUpdateActivity: boolean;
impersonationId: string | null;
locale: string; locale: string;
symbol: string; symbol: string;
} }

1
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

@ -146,7 +146,6 @@ export class GfHomeWatchlistComponent implements OnInit {
GfCreateWatchlistItemDialogComponent, GfCreateWatchlistItemDialogComponent,
CreateWatchlistItemDialogParams CreateWatchlistItemDialogParams
>(GfCreateWatchlistItemDialogComponent, { >(GfCreateWatchlistItemDialogComponent, {
autoFocus: false,
data: { data: {
deviceType: this.deviceType(), deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE locale: this.user?.settings?.locale ?? DEFAULT_LOCALE

9
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -22,6 +22,7 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
DestroyRef, DestroyRef,
inject, inject,
@ -50,6 +51,7 @@ import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { eyeOffOutline, eyeOutline } from 'ionicons/icons'; import { eyeOffOutline, eyeOutline } from 'ionicons/icons';
import ms from 'ms'; import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY, throwError } from 'rxjs'; import { EMPTY, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@ -108,10 +110,17 @@ export class GfUserAccountSettingsComponent implements OnInit {
'uk', 'uk',
'zh' 'zh'
]; ];
protected readonly previewDate = new Date().toISOString();
protected readonly previewValue = 9999.99;
protected user: User; protected user: User;
protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
private readonly settingsStorageService = inject(SettingsStorageService); private readonly settingsStorageService = inject(SettingsStorageService);

24
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -146,7 +146,7 @@
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div class="align-items-center d-flex mb-2"> <div class="align-items-center d-flex mb-4">
<div class="pr-1 w-50"> <div class="pr-1 w-50">
<div i18n>Locale</div> <div i18n>Locale</div>
<div class="hint-text text-muted"> <div class="hint-text text-muted">
@ -154,7 +154,10 @@
</div> </div>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field
appearance="outline"
class="w-100 with-value-hint"
>
<mat-select <mat-select
name="locale" name="locale"
[disabled]="!hasPermissionToUpdateUserSettings" [disabled]="!hasPermissionToUpdateUserSettings"
@ -168,6 +171,23 @@
<mat-option [value]="locale">{{ locale }}</mat-option> <mat-option [value]="locale">{{ locale }}</mat-option>
} }
</mat-select> </mat-select>
<mat-hint class="d-flex mt-2 text-muted">
<gf-value
[deviceType]="deviceType()"
[isDate]="true"
[isLoading]="isLoading"
[locale]="user.settings.locale"
[value]="previewDate"
/>
<span class="mx-1">·</span>
<gf-value
[isCurrency]="true"
[isLoading]="isLoading"
[locale]="user.settings.locale"
[unit]="user.settings.baseCurrency"
[value]="previewValue"
/>
</mat-hint>
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>

21
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -12,6 +12,7 @@ import {
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { AccountWithValue } from '@ghostfolio/common/types';
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table'; import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
@ -29,7 +30,7 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; 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 { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@ -48,11 +49,11 @@ import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-ba
templateUrl: './accounts-page.html' templateUrl: './accounts-page.html'
}) })
export class GfAccountsPageComponent implements OnInit { export class GfAccountsPageComponent implements OnInit {
protected accounts: AccountModel[]; protected accounts: AccountWithValue[];
protected activitiesCount = 0; protected activitiesCount = 0;
protected hasImpersonationId: boolean;
protected hasPermissionToCreateAccount: boolean; protected hasPermissionToCreateAccount: boolean;
protected hasPermissionToUpdateAccount: boolean; protected hasPermissionToUpdateAccount: boolean;
protected impersonationId: string | null;
protected totalBalanceInBaseCurrency = 0; protected totalBalanceInBaseCurrency = 0;
protected totalValueInBaseCurrency = 0; protected totalValueInBaseCurrency = 0;
protected user: User; protected user: User;
@ -103,12 +104,16 @@ export class GfAccountsPageComponent implements OnInit {
}); });
} }
protected get hasImpersonationId() {
return !!this.impersonationId;
}
public ngOnInit() { public ngOnInit() {
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.impersonationId = impersonationId;
}); });
this.userService.stateChanged this.userService.stateChanged
@ -155,7 +160,7 @@ export class GfAccountsPageComponent implements OnInit {
}); });
} }
protected onUpdateAccount(aAccount: AccountModel) { protected onUpdateAccount(aAccount: AccountWithValue) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { accountId: aAccount.id, editDialog: true } queryParams: { accountId: aAccount.id, editDialog: true }
}); });
@ -194,7 +199,7 @@ export class GfAccountsPageComponent implements OnInit {
name, name,
platformId, platformId,
tags tags
}: AccountModel & { tags?: Tag[] }) { }: AccountWithValue & { tags?: Tag[] }) {
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open<
GfCreateOrUpdateAccountDialogComponent, GfCreateOrUpdateAccountDialogComponent,
CreateOrUpdateAccountDialogParams CreateOrUpdateAccountDialogParams
@ -251,11 +256,11 @@ export class GfAccountsPageComponent implements OnInit {
data: { data: {
accountId: aAccountId, accountId: aAccountId,
deviceType: this.deviceType(), deviceType: this.deviceType(),
hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateActivity: hasPermissionToCreateActivity:
!this.hasImpersonationId && !this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createActivity) && hasPermission(this.user?.permissions, permissions.createActivity) &&
!this.user?.settings?.isRestrictedView !this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId
}, },
height: this.deviceType() === 'mobile' ? '98vh' : '80vh', height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'

8
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 { 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 { export interface CreateOrUpdateAccountDialogParams {
account: Omit<Account, 'createdAt' | 'id' | 'updatedAt' | 'userId'> & { account: Omit<
AccountWithBalance,
'createdAt' | 'id' | 'updatedAt' | 'userId'
> & {
id: string | null; id: string | null;
tags?: Tag[]; tags?: Tag[];
}; };

23
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -17,7 +17,11 @@ import {
PortfolioPosition, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } 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 { MarketAdvanced } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
@ -85,7 +89,6 @@ export class GfAllocationsPageComponent implements OnInit {
protected readonly deviceType = computed( protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
); );
protected hasImpersonationId: boolean;
protected holdings: { protected holdings: {
[symbol: string]: Pick< [symbol: string]: Pick<
PortfolioPosition['assetProfile'], PortfolioPosition['assetProfile'],
@ -97,6 +100,7 @@ export class GfAllocationsPageComponent implements OnInit {
| 'name' | 'name'
> & { etfProvider: string; value: number }; > & { etfProvider: string; value: number };
}; };
protected impersonationId: string | null;
protected isLoading = false; protected isLoading = false;
protected markets: PortfolioDetails['markets']; protected markets: PortfolioDetails['markets'];
protected marketsAdvanced: { protected marketsAdvanced: {
@ -169,7 +173,7 @@ export class GfAllocationsPageComponent implements OnInit {
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.impersonationId = impersonationId;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -224,7 +228,12 @@ export class GfAllocationsPageComponent implements OnInit {
} }
protected showValuesInPercentage() { protected showValuesInPercentage() {
return this.hasImpersonationId || this.user?.settings?.isRestrictedView; return (
hasReadRestrictedAccessPermission({
accesses: this.user?.access,
impersonationId: this.impersonationId
}) || this.user?.settings?.isRestrictedView
);
} }
private extractCurrency({ private extractCurrency({
@ -618,11 +627,11 @@ export class GfAllocationsPageComponent implements OnInit {
data: { data: {
accountId: aAccountId, accountId: aAccountId,
deviceType: this.deviceType(), deviceType: this.deviceType(),
hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateActivity: hasPermissionToCreateActivity:
!this.hasImpersonationId && !this.impersonationId &&
hasPermission(this.user?.permissions, permissions.createActivity) && hasPermission(this.user?.permissions, permissions.createActivity) &&
!this.user?.settings?.isRestrictedView !this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId
}, },
height: this.deviceType() === 'mobile' ? '98vh' : '80vh', height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'

34
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -16,7 +16,11 @@ import {
ToggleOption, ToggleOption,
User User
} from '@ghostfolio/common/interfaces'; } 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 type { AiPromptMode, GroupBy } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -79,8 +83,8 @@ export class GfAnalysisPageComponent implements OnInit {
protected bottom3: PortfolioPosition[]; protected bottom3: PortfolioPosition[];
protected dividendsByGroup: InvestmentItem[]; protected dividendsByGroup: InvestmentItem[];
protected readonly dividendTimelineDataLabel = $localize`Dividend`; protected readonly dividendTimelineDataLabel = $localize`Dividend`;
protected hasImpersonationId: boolean;
protected hasPermissionToReadAiPrompt: boolean; protected hasPermissionToReadAiPrompt: boolean;
protected impersonationId: string | null;
protected investments: InvestmentItem[]; protected investments: InvestmentItem[];
protected readonly investmentTimelineDataLabel = $localize`Invested Capital`; protected readonly investmentTimelineDataLabel = $localize`Invested Capital`;
protected investmentsByGroup: InvestmentItem[]; protected investmentsByGroup: InvestmentItem[];
@ -100,6 +104,7 @@ export class GfAnalysisPageComponent implements OnInit {
protected performanceDataItemsInPercentage: HistoricalDataItem[]; protected performanceDataItemsInPercentage: HistoricalDataItem[];
protected readonly portfolioEvolutionDataLabel = $localize`Investment`; protected readonly portfolioEvolutionDataLabel = $localize`Investment`;
protected precision = 2; protected precision = 2;
protected savingsRatePerMonth: number | undefined;
protected streaks: PortfolioInvestmentsResponse['streaks']; protected streaks: PortfolioInvestmentsResponse['streaks'];
protected top3: PortfolioPosition[]; protected top3: PortfolioPosition[];
protected unitCurrentStreak: string; protected unitCurrentStreak: string;
@ -131,18 +136,13 @@ export class GfAnalysisPageComponent implements OnInit {
} }
get savingsRate() { get savingsRate() {
const savingsRatePerMonth = if (!this.savingsRatePerMonth) {
this.hasImpersonationId || this.user.settings.isRestrictedView
? undefined
: this.user?.settings?.savingsRate;
if (savingsRatePerMonth === undefined) {
return undefined; return undefined;
} }
return this.mode() === 'year' return this.mode() === 'year'
? savingsRatePerMonth * 12 ? this.savingsRatePerMonth * 12
: savingsRatePerMonth; : this.savingsRatePerMonth;
} }
public ngOnInit() { public ngOnInit() {
@ -150,7 +150,7 @@ export class GfAnalysisPageComponent implements OnInit {
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.impersonationId = impersonationId;
this.changeDetectorRef.markForCheck(); 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() { private fetchDividendsAndInvestments() {
this.isLoadingDividendTimelineChart = true; this.isLoadingDividendTimelineChart = true;
this.isLoadingInvestmentTimelineChart = true; this.isLoadingInvestmentTimelineChart = true;
@ -267,8 +276,9 @@ export class GfAnalysisPageComponent implements OnInit {
range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ investments, streaks }) => { .subscribe(({ investments, savingsRate, streaks }) => {
this.investmentsByGroup = investments; this.investmentsByGroup = investments;
this.savingsRatePerMonth = savingsRate;
this.streaks = streaks; this.streaks = streaks;
this.unitCurrentStreak = this.unitCurrentStreak =
this.mode() === 'year' this.mode() === 'year'

12
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -398,9 +398,7 @@
[benchmarkDataLabel]="portfolioEvolutionDataLabel" [benchmarkDataLabel]="portfolioEvolutionDataLabel"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[historicalDataItems]="performanceDataItems" [historicalDataItems]="performanceDataItems"
[isInPercentage]=" [isInPercentage]="showValuesInPercentage()"
hasImpersonationId || user.settings.isRestrictedView
"
[isLoading]="isLoadingInvestmentChart" [isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />
@ -456,9 +454,7 @@
[benchmarkDataLabel]="investmentTimelineDataLabel" [benchmarkDataLabel]="investmentTimelineDataLabel"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[groupBy]="mode()" [groupBy]="mode()"
[isInPercentage]=" [isInPercentage]="showValuesInPercentage()"
hasImpersonationId || user.settings.isRestrictedView
"
[isLoading]="isLoadingInvestmentTimelineChart" [isLoading]="isLoadingInvestmentTimelineChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[savingsRate]="savingsRate" [savingsRate]="savingsRate"
@ -493,9 +489,7 @@
[benchmarkDataLabel]="dividendTimelineDataLabel" [benchmarkDataLabel]="dividendTimelineDataLabel"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[groupBy]="mode()" [groupBy]="mode()"
[isInPercentage]=" [isInPercentage]="showValuesInPercentage()"
hasImpersonationId || user.settings.isRestrictedView
"
[isLoading]="isLoadingDividendTimelineChart" [isLoading]="isLoadingDividendTimelineChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />

2
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -21,7 +21,7 @@
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[projectedTotalAmount]="user?.settings?.projectedTotalAmount" [projectedTotalAmount]="user?.settings?.projectedTotalAmount"
[retirementDate]="user?.settings?.retirementDate" [retirementDate]="user?.settings?.retirementDate"
[savingsRate]="user?.settings?.savingsRate" [savingsRate]="hasImpersonationId ? 0 : user?.settings?.savingsRate"
[style.opacity]=" [style.opacity]="
user?.subscription?.type === 'Basic' ? '0.67' : 'initial' user?.subscription?.type === 'Basic' ? '0.67' : 'initial'
" "

2
libs/common/src/lib/config.ts

@ -115,7 +115,6 @@ export const DEFAULT_REDACTED_PATHS = [
'accounts[*].interestInBaseCurrency', 'accounts[*].interestInBaseCurrency',
'accounts[*].value', 'accounts[*].value',
'accounts[*].valueInBaseCurrency', 'accounts[*].valueInBaseCurrency',
'activities[*].account.balance',
'activities[*].account.comment', 'activities[*].account.comment',
'activities[*].assetProfile.symbolMapping', 'activities[*].assetProfile.symbolMapping',
'activities[*].assetProfile.watchedByCount', 'activities[*].assetProfile.watchedByCount',
@ -128,7 +127,6 @@ export const DEFAULT_REDACTED_PATHS = [
'activities[*].valueInBaseCurrency', 'activities[*].valueInBaseCurrency',
'balance', 'balance',
'balanceInBaseCurrency', 'balanceInBaseCurrency',
'balances[*].account.balance',
'balances[*].account.comment', 'balances[*].account.comment',
'balances[*].value', 'balances[*].value',
'balances[*].valueInBaseCurrency', 'balances[*].valueInBaseCurrency',

7
libs/common/src/lib/dtos/create-account.dto.ts

@ -12,8 +12,13 @@ import {
import { isString } from 'lodash'; import { isString } from 'lodash';
export class CreateAccountDto { 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() @IsNumber()
balance: number; @IsOptional()
balance?: number;
@IsOptional() @IsOptional()
@IsString() @IsString()

7
libs/common/src/lib/dtos/update-account.dto.ts

@ -12,8 +12,13 @@ import {
import { isString } from 'lodash'; import { isString } from 'lodash';
export class UpdateAccountDto { export class UpdateAccountDto {
/**
* The balance, stored as the account balance of today.
* Optional because the account balances are the source of truth.
*/
@IsNumber() @IsNumber()
balance: number; @IsOptional()
balance?: number;
@IsOptional() @IsOptional()
@IsString() @IsString()

25
libs/common/src/lib/helper.spec.ts

@ -4,6 +4,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
extractNumberFromString, extractNumberFromString,
getCountryCodeFromCurrency,
getNumberFormatGroup, getNumberFormatGroup,
getStringOrNull, getStringOrNull,
getStringOrUndefined, 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', () => { describe('Get number format group', () => {
let languageGetter: jest.SpyInstance<string, [], any>; let languageGetter: jest.SpyInstance<string, [], any>;

11
libs/common/src/lib/helper.ts

@ -279,6 +279,17 @@ export function getCurrencyFromSymbol(aSymbol = '') {
return aSymbol.replace(DEFAULT_CURRENCY, ''); 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 { export function getCountryName({ code }: { code: string }): string {
try { try {
return ( return (

1
libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts

@ -2,5 +2,6 @@ import { InvestmentItem } from '../investment-item.interface';
export interface PortfolioInvestmentsResponse { export interface PortfolioInvestmentsResponse {
investments: InvestmentItem[]; investments: InvestmentItem[];
savingsRate?: number;
streaks: { currentStreak: number; longestStreak: number }; streaks: { currentStreak: number; longestStreak: number };
} }

12
libs/common/src/lib/permissions.ts

@ -1,6 +1,6 @@
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Role } from '@prisma/client'; import { Access, Role } from '@prisma/client';
export const permissions = { export const permissions = {
accessAdminControl: 'accessAdminControl', accessAdminControl: 'accessAdminControl',
@ -198,17 +198,17 @@ export function hasPermission(
} }
export function hasReadRestrictedAccessPermission({ export function hasReadRestrictedAccessPermission({
impersonationId, accesses = [],
user impersonationId
}: { }: {
impersonationId: string; accesses?: Pick<Access, 'id' | 'permissions'>[];
user: UserWithSettings; impersonationId: string | null;
}) { }) {
if (!impersonationId) { if (!impersonationId) {
return false; return false;
} }
const access = user?.accessesGet?.find(({ id }) => { const access = accesses.find(({ id }) => {
return id === impersonationId; return id === impersonationId;
}); });

5
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;
};

6
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; activitiesCount: number;
allocationInPercentage: number; allocationInPercentage: number;
balanceInBaseCurrency: number; balanceInBaseCurrency: number;

2
libs/common/src/lib/types/index.ts

@ -1,5 +1,6 @@
import type { AccessType } from './access-type.type'; import type { AccessType } from './access-type.type';
import type { AccessWithGranteeUser } from './access-with-grantee-user.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 { AccountWithPlatform } from './account-with-platform.type';
import type { AccountWithValue } from './account-with-value.type'; import type { AccountWithValue } from './account-with-value.type';
import type { AiPromptMode } from './ai-prompt-mode.type'; import type { AiPromptMode } from './ai-prompt-mode.type';
@ -28,6 +29,7 @@ import type { ViewMode } from './view-mode.type';
export type { export type {
AccessType, AccessType,
AccessWithGranteeUser, AccessWithGranteeUser,
AccountWithBalance,
AccountWithPlatform, AccountWithPlatform,
AccountWithValue, AccountWithValue,
AiPromptMode, AiPromptMode,

20
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 { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
@ -14,16 +16,18 @@ import { NotificationService } from '../notifications';
import { GfValueComponent } from '../value'; import { GfValueComponent } from '../value';
import { GfAccountsTableComponent } from './accounts-table.component'; import { GfAccountsTableComponent } from './accounts-table.component';
const accounts = [ const accounts: AccountWithValue[] = [
{ {
activitiesCount: 0, activitiesCount: 0,
allocationInPercentage: null, allocationInPercentage: 0.002574748676949956,
balance: 278, balance: 278,
balanceInBaseCurrency: 278, balanceInBaseCurrency: 278,
comment: null, comment: null,
createdAt: new Date('2025-06-01T06:52:49.063Z'), createdAt: new Date('2025-06-01T06:52:49.063Z'),
currency: 'USD', currency: 'USD',
dividendInBaseCurrency: 0,
id: '460d7401-ca43-4ed4-b08e-349f1822e9db', id: '460d7401-ca43-4ed4-b08e-349f1822e9db',
interestInBaseCurrency: 0,
name: 'Coinbase Account', name: 'Coinbase Account',
platform: { platform: {
id: '8dc24b88-bb92-4152-af25-fe6a31643e26', id: '8dc24b88-bb92-4152-af25-fe6a31643e26',
@ -38,13 +42,15 @@ const accounts = [
}, },
{ {
activitiesCount: 0, activitiesCount: 0,
allocationInPercentage: null, allocationInPercentage: 0.11114023065971035,
balance: 12000, balance: 12000,
balanceInBaseCurrency: 12000, balanceInBaseCurrency: 12000,
comment: null, comment: null,
createdAt: new Date('2025-06-01T06:48:53.055Z'), createdAt: new Date('2025-06-01T06:48:53.055Z'),
currency: 'USD', currency: 'USD',
dividendInBaseCurrency: 0,
id: '6d773e31-0583-4c85-a247-e69870b4f1ee', id: '6d773e31-0583-4c85-a247-e69870b4f1ee',
interestInBaseCurrency: 0,
name: 'Private Banking Account', name: 'Private Banking Account',
platform: { platform: {
id: '43e8fcd1-5b79-4100-b678-d2229bd1660d', id: '43e8fcd1-5b79-4100-b678-d2229bd1660d',
@ -59,13 +65,15 @@ const accounts = [
}, },
{ {
activitiesCount: 12, activitiesCount: 12,
allocationInPercentage: null, allocationInPercentage: 0.8862850206633397,
balance: 150.2, balance: 150.2,
balanceInBaseCurrency: 150.2, balanceInBaseCurrency: 150.2,
comment: null, comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'), createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD', currency: 'USD',
dividendInBaseCurrency: 0,
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625', id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
interestInBaseCurrency: 0,
name: 'Trading Account', name: 'Trading Account',
platform: { platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737', id: '9da3a8a7-4795-43e3-a6db-ccb914189737',
@ -73,10 +81,10 @@ const accounts = [
url: 'https://interactivebrokers.com' url: 'https://interactivebrokers.com'
}, },
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737', platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
valueInBaseCurrency: 95693.70321466809,
updatedAt: new Date('2025-06-01T06:53:10.569Z'), updatedAt: new Date('2025-06-01T06:53:10.569Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e', userId: '081aa387-487d-4438-83a4-3060eb2a016e',
value: 95693.70321466809 value: 95693.70321466809,
valueInBaseCurrency: 95693.70321466809
} }
]; ];

14
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -4,6 +4,7 @@ import {
getLowercase, getLowercase,
isAccountExcluded isAccountExcluded
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { AccountWithValue } from '@ghostfolio/common/types';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfValueComponent } from '@ghostfolio/ui/value'; 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 { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { Router, RouterModule } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { Account } from '@prisma/client';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
arrowRedoOutline, arrowRedoOutline,
@ -55,7 +55,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
templateUrl: './accounts-table.component.html' templateUrl: './accounts-table.component.html'
}) })
export class GfAccountsTableComponent { export class GfAccountsTableComponent {
public readonly accounts = input.required<Account[]>(); public readonly accounts = input.required<AccountWithValue[]>();
public readonly activitiesCount = input<number>(); public readonly activitiesCount = input<number>();
public readonly baseCurrency = input<string>(); public readonly baseCurrency = input<string>();
public readonly hasPermissionToOpenDetails = input(true); public readonly hasPermissionToOpenDetails = input(true);
@ -71,12 +71,12 @@ export class GfAccountsTableComponent {
public readonly totalValueInBaseCurrency = input<number>(); public readonly totalValueInBaseCurrency = input<number>();
public readonly accountDeleted = output<string>(); public readonly accountDeleted = output<string>();
public readonly accountToUpdate = output<Account>(); public readonly accountToUpdate = output<AccountWithValue>();
public readonly transferBalance = output<void>(); public readonly transferBalance = output<void>();
public readonly sort = viewChild.required(MatSort); public readonly sort = viewChild.required(MatSort);
protected readonly dataSource = new MatTableDataSource<Account>([]); protected readonly dataSource = new MatTableDataSource<AccountWithValue>([]);
protected readonly displayedColumns = computed(() => { protected readonly displayedColumns = computed(() => {
const columns = ['status', 'account', 'platform']; 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); return isAccountExcluded(account);
} }
@ -173,7 +175,7 @@ export class GfAccountsTableComponent {
this.transferBalance.emit(); this.transferBalance.emit();
} }
protected onUpdateAccount(aAccount: Account) { protected onUpdateAccount(aAccount: AccountWithValue) {
this.accountToUpdate.emit(aAccount); this.accountToUpdate.emit(aAccount);
} }
} }

5
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'), updatedAt: new Date('2025-05-31T18:43:01.840Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e', userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: { account: {
balance: 150.2,
comment: null, comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'), createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD', currency: 'USD',
@ -105,7 +104,6 @@ const activities: Activity[] = [
updatedAt: new Date('2025-05-31T18:46:14.175Z'), updatedAt: new Date('2025-05-31T18:46:14.175Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e', userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: { account: {
balance: 150.2,
comment: null, comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'), createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD', currency: 'USD',
@ -171,7 +169,6 @@ const activities: Activity[] = [
updatedAt: new Date('2025-05-31T18:49:54.064Z'), updatedAt: new Date('2025-05-31T18:49:54.064Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e', userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: { account: {
balance: 150.2,
comment: null, comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'), createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD', currency: 'USD',
@ -237,7 +234,6 @@ const activities: Activity[] = [
updatedAt: new Date('2025-05-31T18:48:48.209Z'), updatedAt: new Date('2025-05-31T18:48:48.209Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e', userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: { account: {
balance: 150.2,
comment: null, comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'), createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD', currency: 'USD',
@ -303,7 +299,6 @@ const activities: Activity[] = [
updatedAt: new Date('2025-05-31T18:46:44.616Z'), updatedAt: new Date('2025-05-31T18:46:44.616Z'),
userId: '081aa387-487d-4438-83a4-3060eb2a016e', userId: '081aa387-487d-4438-83a4-3060eb2a016e',
account: { account: {
balance: 150.2,
comment: null, comment: null,
createdAt: new Date('2025-05-31T13:00:13.940Z'), createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD', currency: 'USD',

10
libs/ui/src/lib/currency-selector/currency-selector.component.html

@ -1,9 +1,14 @@
@if (emojiFlagOfSelectedCurrency) {
<span class="flex-shrink-0 mr-1">{{ emojiFlagOfSelectedCurrency }}</span>
}
<input <input
autocapitalize="off" autocapitalize="off"
autocomplete="off" autocomplete="off"
matInput matInput
[formControl]="control" [formControl]="control"
[matAutocomplete]="currencyAutocomplete" [matAutocomplete]="currencyAutocomplete"
[matAutocompleteConnectedTo]="autocompleteOrigin"
/> />
<mat-autocomplete <mat-autocomplete
@ -12,7 +17,10 @@
> >
@for (currency of filteredCurrencies; track currency) { @for (currency of filteredCurrencies; track currency) {
<mat-option class="line-height-1" [value]="currency"> <mat-option class="line-height-1" [value]="currency">
{{ currency }} <span class="align-items-center d-flex">
<span class="mr-1">{{ getEmojiFlagFromCurrency(currency) }}</span>
<span>{{ currency }}</span>
</span>
</mat-option> </mat-option>
} }
</mat-autocomplete> </mat-autocomplete>

3
libs/ui/src/lib/currency-selector/currency-selector.component.scss

@ -1,3 +0,0 @@
:host {
display: block;
}

99
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<GfCurrencySelectorComponent> = {
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: `
<form [formGroup]="formGroup">
<mat-form-field appearance="outline" class="w-100">
<mat-label>Currency</mat-label>
<gf-currency-selector
formControlName="currency"
[currencies]="currencies"
/>
</mat-form-field>
</form>
`
};
}
};
export default meta;
type Story = StoryObj<GfCurrencySelectorComponent>;
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
}
};

30
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 { FocusMonitor } from '@angular/cdk/a11y';
import { import {
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
@ -24,9 +29,11 @@ import {
import { import {
MatAutocomplete, MatAutocomplete,
MatAutocompleteModule, MatAutocompleteModule,
MatAutocompleteOrigin,
MatOption MatOption
} from '@angular/material/autocomplete'; } from '@angular/material/autocomplete';
import { import {
MAT_FORM_FIELD,
MatFormFieldControl, MatFormFieldControl,
MatFormFieldModule MatFormFieldModule
} from '@angular/material/form-field'; } from '@angular/material/form-field';
@ -39,7 +46,8 @@ import { AbstractMatFormField } from '../shared/abstract-mat-form-field';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { host: {
'[attr.aria-describedBy]': 'describedBy', '[attr.aria-describedBy]': 'describedBy',
'[id]': 'id' '[id]': 'id',
class: 'align-items-center d-flex'
}, },
imports: [ imports: [
FormsModule, FormsModule,
@ -56,7 +64,6 @@ import { AbstractMatFormField } from '../shared/abstract-mat-form-field';
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-currency-selector', selector: 'gf-currency-selector',
styleUrls: ['./currency-selector.component.scss'],
templateUrl: 'currency-selector.component.html' templateUrl: 'currency-selector.component.html'
}) })
export class GfCurrencySelectorComponent export class GfCurrencySelectorComponent
@ -72,6 +79,7 @@ export class GfCurrencySelectorComponent
public readonly formControlName = input.required<string>(); public readonly formControlName = input.required<string>();
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly formField = inject(MAT_FORM_FIELD);
private readonly input = viewChild.required(MatInput); private readonly input = viewChild.required(MatInput);
public constructor( public constructor(
@ -86,8 +94,20 @@ export class GfCurrencySelectorComponent
this.controlType = 'currency-selector'; 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() { public override get empty() {
return this.input().empty; return !this.control.value;
} }
public override set value(value: string | null) { public override set value(value: string | null) {
@ -99,6 +119,10 @@ export class GfCurrencySelectorComponent
this.input().focus(); this.input().focus();
} }
public getEmojiFlagFromCurrency(aCurrency = '') {
return getEmojiFlag(getCountryCodeFromCurrency(aCurrency));
}
public ngOnInit() { public ngOnInit() {
if (this.disabled) { if (this.disabled) {
this.control.disable(); this.control.disable();

7
libs/ui/src/lib/shared/abstract-mat-form-field.ts

@ -1,7 +1,7 @@
import { FocusMonitor } from '@angular/cdk/a11y'; import { FocusMonitor } from '@angular/cdk/a11y';
import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { import {
Component, Directive,
DoCheck, DoCheck,
ElementRef, ElementRef,
HostBinding, HostBinding,
@ -13,10 +13,7 @@ import { ControlValueAccessor, NgControl, Validators } from '@angular/forms';
import { MatFormFieldControl } from '@angular/material/form-field'; import { MatFormFieldControl } from '@angular/material/form-field';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@Component({ @Directive()
template: '',
standalone: false
})
export abstract class AbstractMatFormField<T> export abstract class AbstractMatFormField<T>
implements ControlValueAccessor, DoCheck, MatFormFieldControl<T>, OnDestroy implements ControlValueAccessor, DoCheck, MatFormFieldControl<T>, OnDestroy
{ {

2
prisma/migrations/20260805120000_removed_balance_from_account/migration.sql

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Account" DROP COLUMN "balance";

1
prisma/schema.prisma

@ -27,7 +27,6 @@ model Access {
model Account { model Account {
activities Order[] activities Order[]
balance Float @default(0)
balances AccountBalance[] balances AccountBalance[]
comment String? comment String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())

1
test/import/not-ok/invalid-platform.json

@ -5,7 +5,6 @@
}, },
"accounts": [ "accounts": [
{ {
"balance": 0,
"balances": [], "balances": [],
"currency": "USD", "currency": "USD",
"id": "e62be662-a2c8-4cff-8b79-dc0a46576659", "id": "e62be662-a2c8-4cff-8b79-dc0a46576659",

1
test/import/ok/500-activities.json

@ -5,7 +5,6 @@
}, },
"accounts": [ "accounts": [
{ {
"balance": 2000,
"currency": "USD", "currency": "USD",
"id": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0", "id": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0",
"name": "My Online Trading Account", "name": "My Online Trading Account",

1
test/import/ok/derived-currency.json

@ -5,7 +5,6 @@
}, },
"accounts": [ "accounts": [
{ {
"balance": 2000,
"currency": "USD", "currency": "USD",
"id": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0", "id": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0",
"name": "My Online Trading Account", "name": "My Online Trading Account",

1
test/import/ok/sample.json

@ -5,7 +5,6 @@
}, },
"accounts": [ "accounts": [
{ {
"balance": 2000,
"balances": [ "balances": [
{ {
"date": "2024-12-31T00:00:00.000Z", "date": "2024-12-31T00:00:00.000Z",

Loading…
Cancel
Save