Browse Source

Merge branch 'main' into task/improve-check-for-duplicates-in-preview-step-of-activities-import

pull/7541/head
Thomas Kaul 3 weeks ago
parent
commit
6aa866746b
  1. 24
      CHANGELOG.md
  2. 92
      apps/api/src/app/account/account.controller.ts
  3. 80
      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. 30
      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. 13
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  18. 6
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  19. 5
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  20. 4
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  21. 2
      apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts
  22. 1
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  23. 9
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  24. 24
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  25. 21
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  26. 4
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  27. 8
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts
  28. 22
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts
  29. 54
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html
  30. 16
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  31. 23
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  32. 34
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  33. 12
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  34. 2
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  35. 2
      libs/common/src/lib/config.ts
  36. 7
      libs/common/src/lib/dtos/create-account.dto.ts
  37. 7
      libs/common/src/lib/dtos/update-account.dto.ts
  38. 25
      libs/common/src/lib/helper.spec.ts
  39. 11
      libs/common/src/lib/helper.ts
  40. 1
      libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts
  41. 12
      libs/common/src/lib/permissions.ts
  42. 5
      libs/common/src/lib/types/account-with-balance.type.ts
  43. 6
      libs/common/src/lib/types/account-with-value.type.ts
  44. 2
      libs/common/src/lib/types/index.ts
  45. 26
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  46. 20
      libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts
  47. 14
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  48. 13
      libs/ui/src/lib/activities-table/activities-table.component.html
  49. 5
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  50. 10
      libs/ui/src/lib/currency-selector/currency-selector.component.html
  51. 3
      libs/ui/src/lib/currency-selector/currency-selector.component.scss
  52. 99
      libs/ui/src/lib/currency-selector/currency-selector.component.stories.ts
  53. 30
      libs/ui/src/lib/currency-selector/currency-selector.component.ts
  54. 7
      libs/ui/src/lib/entity-logo/entity-logo.component.html
  55. 12
      libs/ui/src/lib/entity-logo/entity-logo.component.scss
  56. 16
      libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts
  57. 12
      libs/ui/src/lib/entity-logo/entity-logo.component.ts
  58. 7
      libs/ui/src/lib/mocks/entity-logo-image-source.service.mock.ts
  59. 13
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html
  60. 4
      package-lock.json
  61. 2
      package.json
  62. 2
      prisma/migrations/20260805120000_removed_balance_from_account/migration.sql
  63. 1
      prisma/schema.prisma
  64. 1
      test/import/not-ok/invalid-platform.json
  65. 1
      test/import/ok/500-activities.json
  66. 1
      test/import/ok/derived-currency.json
  67. 1
      test/import/ok/sample.json

24
CHANGELOG.md

@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### 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 activities import (regardless of the account)
- Improved the check for duplicates in the preview step of the import dividends dialog (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 - 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 - 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 ## 3.43.0 - 2026-08-06
### Added ### Added

92
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,52 +259,50 @@ 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,
data: { tagIds,
...accountData, data: {
platform: { connect: { id: platformId } }, ...accountData,
user: { connect: { id: this.request.user.id } } platform: { connect: { id: platformId } },
}, user: { connect: { id: this.request.user.id } }
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
}, },
this.request.user.id, userId: this.request.user.id,
tagIds where: {
); id_userId: {
id,
userId: this.request.user.id
}
}
});
} 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,
data: { tagIds,
...accountData, data: {
platform: originalAccount.platformId ...accountData,
? { disconnect: true } platform: originalAccount.platformId
: undefined, ? { disconnect: true }
user: { connect: { id: this.request.user.id } } : undefined,
}, user: { connect: { id: this.request.user.id } }
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
}, },
this.request.user.id, userId: this.request.user.id,
tagIds where: {
); id_userId: {
id,
userId: this.request.user.id
}
}
});
} }
} }
} }

80
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 {
} }
}); });
await this.accountBalanceService.createOrUpdateAccountBalance({ if (!isNil(balance)) {
accountId: account.id, await this.accountBalanceService.createOrUpdateAccountBalance({
balance: data.balance, balance,
date: format(new Date(), DATE_FORMAT), userId,
userId: aUserId accountId: account.id,
}); date: format(new Date(), DATE_FORMAT)
});
}
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: Prisma.AccountUpdateInput; data,
where: Prisma.AccountWhereUniqueInput; tagIds,
}, userId,
aUserId: string, where
tagIds?: string[] }: {
): Promise<Account> { balance?: number;
const { data, where } = params; data: Prisma.AccountUpdateInput;
tagIds?: string[];
await this.tagService.validateTagIds({ tagIds, userId: aUserId }); userId: string;
where: Prisma.AccountWhereUniqueInput;
}): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId });
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
}); });
await this.accountBalanceService.createOrUpdateAccountBalance({ if (!isNil(balance)) {
accountId: account.id, await this.accountBalanceService.createOrUpdateAccountBalance({
balance: data.balance as number, balance,
date: format(new Date(), DATE_FORMAT), userId,
userId: aUserId accountId: account.id,
}); date: format(new Date(), DATE_FORMAT)
});
}
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

@ -411,6 +411,7 @@ export class ImportService {
); );
const account = omit(accountWithBalances, [ const account = omit(accountWithBalances, [
'balance',
'balances', 'balances',
'isExcluded', 'isExcluded',
'tags' 'tags'
@ -464,11 +465,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 (accountWithSameIdOfOtherUser && oldAccountId) { if (accountWithSameIdOfOtherUser && oldAccountId) {

30
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 } =
filters, await this.portfolioService.getInvestments({
groupBy, filters,
impersonationId, groupBy,
dateRange: range, impersonationId,
savingsRate: this.request.user?.settings?.settings.savingsRate, dateRange: range,
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 {

13
apps/client/src/app/components/admin-platform/admin-platform.component.html

@ -22,13 +22,12 @@
<ng-container i18n>Name</ng-container> <ng-container i18n>Name</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@if (element.url) { <gf-entity-logo
<gf-entity-logo class="d-inline mr-1"
class="d-inline mr-1" [hasPlaceholder]="true"
[tooltip]="element.name" [tooltip]="element.name"
[url]="element.url" [url]="element.url"
/> />
}
<span>{{ element.name }}</span> <span>{{ element.name }}</span>
</td></ng-container </td></ng-container
> >

6
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -59,7 +59,11 @@
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<gf-entity-logo class="mr-1" [url]="element.url" /> <gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[url]="element.url"
/>
<div> <div>
@if (isGhostfolioDataProvider(element)) { @if (isGhostfolioDataProvider(element)) {
<a <a

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'

4
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html

@ -46,10 +46,11 @@
<div [class.d-none]="platforms?.length < 1"> <div [class.d-none]="platforms?.length < 1">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Platform</mat-label> <mat-label i18n>Platform</mat-label>
@if (selectedPlatform?.url) { @if (selectedPlatform) {
<gf-entity-logo <gf-entity-logo
class="ml-3" class="ml-3"
matPrefix matPrefix
[hasPlaceholder]="true"
[tooltip]="selectedPlatform?.name" [tooltip]="selectedPlatform?.name"
[url]="selectedPlatform?.url" [url]="selectedPlatform?.url"
/> />
@ -71,6 +72,7 @@
<span class="d-flex"> <span class="d-flex">
<gf-entity-logo <gf-entity-logo
class="mr-1" class="mr-1"
[hasPlaceholder]="true"
[tooltip]="platformEntry.name" [tooltip]="platformEntry.name"
[url]="platformEntry.url" [url]="platformEntry.url"
/> />

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[];
}; };

22
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts

@ -61,12 +61,22 @@ export class GfTransferBalanceDialogComponent {
private readonly dialogRef = private readonly dialogRef =
inject<MatDialogRef<GfTransferBalanceDialogComponent>>(MatDialogRef); inject<MatDialogRef<GfTransferBalanceDialogComponent>>(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() { public ngOnInit() {
this.transferBalanceForm.controls.fromAccount.valueChanges.subscribe( this.transferBalanceForm.controls.fromAccount.valueChanges.subscribe(
(id) => { (id) => {
const currency = this.accounts.find((account) => { const currency = this.getAccountById(id)?.currency;
return account.id === id;
})?.currency;
if (currency) { if (currency) {
this.currency = currency; this.currency = currency;
@ -101,4 +111,10 @@ export class GfTransferBalanceDialogComponent {
return null; return null;
} }
private getAccountById(aId: string | null) {
return this.accounts.find(({ id }) => {
return id === aId;
});
}
} }

54
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html

@ -10,16 +10,29 @@
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>From</mat-label> <mat-label i18n>From</mat-label>
<mat-select formControlName="fromAccount"> <mat-select formControlName="fromAccount">
<mat-select-trigger>
<div class="d-flex">
@if (selectedFromAccount) {
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="selectedFromAccount?.platform?.name"
[url]="selectedFromAccount?.platform?.url"
/>
}
<span>{{ selectedFromAccount?.name }}</span>
</div>
</mat-select-trigger>
@for (account of accounts; track account) { @for (account of accounts; track account) {
<mat-option [value]="account.id"> <mat-option [value]="account.id">
<div class="d-flex"> <div class="d-flex">
@if (account.platform?.url) { <gf-entity-logo
<gf-entity-logo class="mr-1"
class="mr-1" [hasPlaceholder]="true"
[tooltip]="account.platform?.name" [tooltip]="account.platform?.name"
[url]="account.platform?.url" [url]="account.platform?.url"
/> />
}
<span>{{ account.name }}</span> <span>{{ account.name }}</span>
</div> </div>
</mat-option> </mat-option>
@ -31,16 +44,29 @@
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>To</mat-label> <mat-label i18n>To</mat-label>
<mat-select formControlName="toAccount"> <mat-select formControlName="toAccount">
<mat-select-trigger>
<div class="d-flex">
@if (selectedToAccount) {
<gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[tooltip]="selectedToAccount?.platform?.name"
[url]="selectedToAccount?.platform?.url"
/>
}
<span>{{ selectedToAccount?.name }}</span>
</div>
</mat-select-trigger>
@for (account of accounts; track account) { @for (account of accounts; track account) {
<mat-option [value]="account.id"> <mat-option [value]="account.id">
<div class="d-flex"> <div class="d-flex">
@if (account.platform?.url) { <gf-entity-logo
<gf-entity-logo class="mr-1"
class="mr-1" [hasPlaceholder]="true"
[tooltip]="account.platform?.name" [tooltip]="account.platform?.name"
[url]="account.platform?.url" [url]="account.platform?.url"
/> />
}
<span>{{ account.name }}</span> <span>{{ account.name }}</span>
</div> </div>
</mat-option> </mat-option>

16
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -87,9 +87,10 @@
<mat-select formControlName="accountId"> <mat-select formControlName="accountId">
<mat-select-trigger> <mat-select-trigger>
<div class="d-flex"> <div class="d-flex">
@if (selectedAccount?.platform?.url) { @if (selectedAccount) {
<gf-entity-logo <gf-entity-logo
class="mr-1" class="mr-1"
[hasPlaceholder]="true"
[tooltip]="selectedAccount?.platform?.name" [tooltip]="selectedAccount?.platform?.name"
[url]="selectedAccount?.platform?.url" [url]="selectedAccount?.platform?.url"
/> />
@ -103,13 +104,12 @@
@for (account of data.accounts; track account) { @for (account of data.accounts; track account) {
<mat-option [value]="account.id"> <mat-option [value]="account.id">
<div class="d-flex"> <div class="d-flex">
@if (account.platform?.url) { <gf-entity-logo
<gf-entity-logo class="mr-1"
class="mr-1" [hasPlaceholder]="true"
[tooltip]="account.platform?.name" [tooltip]="account.platform?.name"
[url]="account.platform?.url" [url]="account.platform?.url"
/> />
}
<span>{{ account.name }}</span> <span>{{ account.name }}</span>
</div> </div>
</mat-option> </mat-option>

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,

26
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -50,13 +50,12 @@
<ng-container i18n>Name</ng-container> <ng-container i18n>Name</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
@if (element.platform?.url) { <gf-entity-logo
<gf-entity-logo class="d-inline d-sm-none mr-1"
class="d-inline d-sm-none mr-1" [hasPlaceholder]="true"
[tooltip]="element.platform?.name" [tooltip]="element.platform?.name"
[url]="element.platform.url" [url]="element.platform?.url"
/> />
}
<span>{{ element.name }}</span> <span>{{ element.name }}</span>
</td> </td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td> <td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
@ -98,13 +97,12 @@
mat-cell mat-cell
> >
<div class="d-flex"> <div class="d-flex">
@if (element.platform?.url) { <gf-entity-logo
<gf-entity-logo class="mr-1"
class="mr-1" [hasPlaceholder]="true"
[tooltip]="element.platform?.name" [tooltip]="element.platform?.name"
[url]="element.platform.url" [url]="element.platform?.url"
/> />
}
<span>{{ element.platform?.name }}</span> <span>{{ element.platform?.name }}</span>
</div> </div>
</td> </td>

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

13
libs/ui/src/lib/activities-table/activities-table.component.html

@ -356,13 +356,12 @@
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex"> <div class="d-flex">
@if (element.account?.platform?.url) { <gf-entity-logo
<gf-entity-logo class="mr-1"
class="mr-1" [hasPlaceholder]="true"
[tooltip]="element.account?.platform?.name" [tooltip]="element.account?.platform?.name"
[url]="element.account?.platform?.url" [url]="element.account?.platform?.url"
/> />
}
<span class="d-none d-lg-block text-nowrap">{{ <span class="d-none d-lg-block text-nowrap">{{
element.account?.name element.account?.name
}}</span> }}</span>

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/entity-logo/entity-logo.component.html

@ -1,8 +1,11 @@
@if (src) { @if (src && !hasError) {
<img <img
onerror="this.style.display = 'none'" class="logo"
[class.large]="size === 'large'" [class.large]="size === 'large'"
[src]="src" [src]="src"
[title]="tooltip ? tooltip : ''" [title]="tooltip ? tooltip : ''"
(error)="onError()"
/> />
} @else if (hasPlaceholder) {
<div class="logo placeholder" [class.large]="size === 'large'"></div>
} }

12
libs/ui/src/lib/entity-logo/entity-logo.component.scss

@ -2,7 +2,7 @@
align-items: center; align-items: center;
display: flex; display: flex;
img { .logo {
border-radius: 0.2rem; border-radius: 0.2rem;
height: 0.8rem; height: 0.8rem;
width: 0.8rem; width: 0.8rem;
@ -11,5 +11,15 @@
height: 1.4rem; height: 1.4rem;
width: 1.4rem; width: 1.4rem;
} }
&.placeholder {
border: 1px solid rgba(var(--dark-dividers));
}
}
}
:host-context(.theme-dark) {
.placeholder {
border-color: rgba(var(--light-dividers));
} }
} }

16
libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts

@ -44,3 +44,19 @@ export const LogoByUrl: Story = {
url: 'https://ghostfol.io' 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'
}
};

12
libs/ui/src/lib/entity-logo/entity-logo.component.ts

@ -18,18 +18,22 @@ import { DataSource } from '@prisma/client';
}) })
export class GfEntityLogoComponent implements OnChanges { export class GfEntityLogoComponent implements OnChanges {
@Input() dataSource: DataSource; @Input() dataSource: DataSource;
@Input() hasPlaceholder = false;
@Input() size: 'large'; @Input() size: 'large';
@Input() symbol: string; @Input() symbol: string;
@Input() tooltip: string; @Input() tooltip: string;
@Input() url: string; @Input() url: string;
public src: string; public hasError = false;
public src?: string;
public constructor( public constructor(
private readonly imageSourceService: EntityLogoImageSourceService private readonly imageSourceService: EntityLogoImageSourceService
) {} ) {}
public ngOnChanges() { public ngOnChanges() {
this.hasError = false;
if (this.dataSource && this.symbol) { if (this.dataSource && this.symbol) {
this.src = this.imageSourceService.getLogoUrlByAssetProfileIdentifier({ this.src = this.imageSourceService.getLogoUrlByAssetProfileIdentifier({
dataSource: this.dataSource, dataSource: this.dataSource,
@ -37,6 +41,12 @@ export class GfEntityLogoComponent implements OnChanges {
}); });
} else if (this.url) { } else if (this.url) {
this.src = this.imageSourceService.getLogoUrlByUrl(this.url); this.src = this.imageSourceService.getLogoUrlByUrl(this.url);
} else {
this.src = undefined;
} }
} }
public onError() {
this.hasError = true;
}
} }

7
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'; 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 { export class EntityLogoImageSourceServiceMock {
public getLogoUrlByAssetProfileIdentifier({ public getLogoUrlByAssetProfileIdentifier({
dataSource, 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 'data:image/png;base64,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';
} }
return ''; return UNAVAILABLE_LOGO_URL;
} }
public getLogoUrlByUrl(url: string) { 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 '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;
} }
} }

13
libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html

@ -7,13 +7,12 @@
@for (account of accounts(); track account.id) { @for (account of accounts(); track account.id) {
<mat-option [value]="account.id"> <mat-option [value]="account.id">
<div class="d-flex"> <div class="d-flex">
@if (account.platform?.url) { <gf-entity-logo
<gf-entity-logo class="mr-1"
class="mr-1" [hasPlaceholder]="true"
[tooltip]="account.platform?.name ?? ''" [tooltip]="account.platform?.name ?? ''"
[url]="account.platform?.url ?? ''" [url]="account.platform?.url ?? ''"
/> />
}
<span>{{ account.name }}</span> <span>{{ account.name }}</span>
</div> </div>
</mat-option> </mat-option>

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.43.0", "version": "3.44.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.43.0", "version": "3.44.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.43.0", "version": "3.44.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",

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