Browse Source

Merge branch 'main' into feature/add-dividend-performance-to-analysis-page

pull/7924/head
Thomas Kaul 1 day ago
committed by GitHub
parent
commit
330fbf31c4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 24
      CHANGELOG.md
  2. 2
      README.md
  3. 2
      apps/api/src/app/auth-device/auth-device.controller.ts
  4. 2
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  5. 2
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  6. 2
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  7. 105
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  8. 8
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  9. 4
      apps/api/src/app/endpoints/mcp/mcp.module.ts
  10. 2
      apps/api/src/app/endpoints/tags/tags.controller.ts
  11. 2
      apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
  12. 198
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  13. 119
      apps/api/src/app/portfolio/portfolio.service.ts
  14. 38
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  15. 11
      apps/api/src/decorators/requires-scope-of-access.decorator.ts
  16. 8
      apps/api/src/guards/access.guard.ts
  17. 31
      apps/api/src/helper/bearer-token.helper.spec.ts
  18. 29
      apps/api/src/helper/bearer-token.helper.ts
  19. 38
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  20. 4
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  21. 2
      apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html
  22. 60
      apps/client/src/locales/messages.ca.xlf
  23. 1
      libs/common/src/lib/interfaces/product.interface.ts
  24. 1407
      libs/common/src/lib/personal-finance-tools.ts
  25. 1
      libs/common/src/lib/types/product-category.type.ts
  26. 4
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  27. 1
      libs/ui/src/lib/i18n.ts
  28. 149
      package-lock.json
  29. 12
      package.json

24
CHANGELOG.md

@ -11,6 +11,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added the dividend performance to the analysis page (experimental) - Added the dividend performance to the analysis page (experimental)
### Changed
- Simplified the portfolio summary by hiding the currency on mobile
- Improved the language localization for Catalan (`ca`)
### Fixed
- Fixed the wrapping of the name column in the accounts table component on mobile
## 3.73.0 - 2026-09-26
### Changed
- Improved the server of the Model Context Protocol (MCP) to list only the tools covered by the scopes of the access (experimental)
- Refreshed the cryptocurrencies list
- Improved the language localization for Catalan (`ca`)
- Upgraded `@rekog/mcp-nest` from version `2.0.2` to `2.0.7`
- Upgraded `bull-board` from version `9.9.0` to `9.10.1`
- Upgraded `zod` from version `4.5.4` to `4.6.5`
### Fixed
- Fixed the value of the holdings excluded from analysis in the portfolio summary
## 3.72.0 - 2026-09-20 ## 3.72.0 - 2026-09-20
### Added ### Added

2
README.md

@ -173,7 +173,7 @@ docker compose -f docker/docker-compose.build.yml up -d
### Home Server Systems (Community) ### Home Server Systems (Community)
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio). Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Easypanel](https://easypanel.io/templates/ghostfolio), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
## Development ## Development

2
apps/api/src/app/auth-device/auth-device.controller.ts

@ -3,7 +3,7 @@ import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-durin
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,

2
apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts

@ -17,7 +17,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission } from '@ghostfolio/common/permissions'; import { hasPermission } from '@ghostfolio/common/permissions';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,

2
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -12,7 +12,7 @@ import {
QuotesResponse QuotesResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,

2
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -8,7 +8,7 @@ import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces'; import { MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,

105
apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts

@ -1,13 +1,26 @@
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator';
import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { AccessGuard } from '@ghostfolio/api/guards/access.guard'; import { AccessGuard } from '@ghostfolio/api/guards/access.guard';
import { Scope, scopes } from '@ghostfolio/common/scopes'; import { getMcpUserOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper';
import {
getScopesOfAccess,
getScopesOfAccessLevel,
Scope,
scopes
} from '@ghostfolio/common/scopes';
import { import {
EXCEPTION_FILTERS_METADATA, EXCEPTION_FILTERS_METADATA,
GUARDS_METADATA GUARDS_METADATA
} from '@nestjs/common/constants'; } from '@nestjs/common/constants';
import { MCP_TOOL_METADATA_KEY, ToolMetadata } from '@rekog/mcp-nest'; import {
AccessMatchMode,
MCP_SCOPES_MATCH_METADATA_KEY,
MCP_SCOPES_METADATA_KEY,
MCP_TOOL_METADATA_KEY,
ToolAuthorizationService,
ToolMetadata
} from '@rekog/mcp-nest';
import { GhostfolioMcpController } from './mcp.controller'; import { GhostfolioMcpController } from './mcp.controller';
@ -35,6 +48,41 @@ function getToolMethodNames() {
); );
} }
/**
* Gives the names of the tools which the transport lists for the request. The
* metadata of each tool is read as the transport reads it.
*/
function getNamesOfListedTools(request: unknown) {
const toolAuthorizationService = new ToolAuthorizationService();
const user = getMcpUserOfBearerToken(request);
return getToolMethodNames()
.map((methodName) => {
return {
metadata: {
...getMetadataOfMethod<ToolMetadata>(
MCP_TOOL_METADATA_KEY,
methodName
),
requiredScopes: getMetadataOfMethod<string[]>(
MCP_SCOPES_METADATA_KEY,
methodName
),
requiredScopesMatch: getMetadataOfMethod<AccessMatchMode>(
MCP_SCOPES_MATCH_METADATA_KEY,
methodName
)
}
};
})
.filter((tool) => {
return toolAuthorizationService.canAccessTool(user, tool);
})
.map(({ metadata: { name } }) => {
return name;
});
}
describe('GhostfolioMcpController', () => { describe('GhostfolioMcpController', () => {
// A tool without the decorator of the scope would be open to every access, // A tool without the decorator of the scope would be open to every access,
// hence a new tool has to declare its scope // hence a new tool has to declare its scope
@ -71,6 +119,59 @@ describe('GhostfolioMcpController', () => {
expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]); expect(toolMethodNamesWithoutGuardOfAccess).toEqual([]);
}); });
it('Lists only the tools to read for an access with the permission "Restricted view"', () => {
expect(
getNamesOfListedTools({
impersonationOfBearerToken: {
isActive: true,
scopes: getScopesOfAccess({
scopes: getScopesOfAccessLevel('READ_RESTRICTED'),
type: 'MCP'
})
}
})
).toEqual([
'get-accounts',
'get-activities',
'get-portfolio',
'get-watchlist'
]);
});
it('Lists every tool for an access with the permission "Restricted view and manage"', () => {
expect(
getNamesOfListedTools({
impersonationOfBearerToken: {
isActive: true,
scopes: getScopesOfAccess({
scopes: getScopesOfAccessLevel(
'CREATE_READ_RESTRICTED_UPDATE_DELETE'
),
type: 'MCP'
})
}
})
).toEqual([
'get-accounts',
'get-activities',
'get-portfolio',
'get-watchlist',
'import-activities',
'search-asset-profiles'
]);
});
it('Lists no tool for an inactive access', () => {
expect(
getNamesOfListedTools({
impersonationOfBearerToken: {
isActive: false,
scopes: getScopesOfAccessLevel('CREATE_READ_RESTRICTED_UPDATE_DELETE')
}
})
).toEqual([]);
});
it('Requires the scope to create an activity for the tool to import activities', () => { it('Requires the scope to create an activity for the tool to import activities', () => {
expect( expect(
getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'importActivities') getMetadataOfMethod<Scope[]>(REQUIRES_SCOPE_KEY, 'importActivities')

8
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -101,12 +101,6 @@ export class GhostfolioMcpController {
return this.mcpService.getWatchlist({ userId }); return this.mcpService.getWatchlist({ userId });
} }
/**
* The transport gives the tool to every client, because it filters the list
* of the tools by the scopes of request.user, which a request of an access
* never has. The guard refuses the call itself, hence the description names
* the permission which the access needs.
*/
@RequiresScopeOfAccess(scopes.activityCreate) @RequiresScopeOfAccess(scopes.activityCreate)
@Tool({ @Tool({
annotations: { annotations: {
@ -115,7 +109,7 @@ export class GhostfolioMcpController {
readOnlyHint: false, readOnlyHint: false,
title: 'Import activities' title: 'Import activities'
}, },
description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the skipped activities. Use search-asset-profiles first unless the exact symbol and data source are already known. An activity is skipped if an equal activity is in the portfolio already, hence send each activity one time only: two equal activities of the same call are both imported. The access needs the permission "Restricted view and manage". At most ${MCP_MAX_ACTIVITIES} activities are imported per call, while the instance can have a lower limit, which an error names. An error does not remove the activities of the same call which are imported already, hence get the activities after an error before you import them again.`, description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the skipped activities. Use search-asset-profiles first unless the exact symbol and data source are already known. An activity is skipped if an equal activity is in the portfolio already, hence send each activity one time only: two equal activities of the same call are both imported. At most ${MCP_MAX_ACTIVITIES} activities are imported per call, while the instance can have a lower limit, which an error names. An error does not remove the activities of the same call which are imported already, hence get the activities after an error before you import them again.`,
name: 'import-activities', name: 'import-activities',
parameters: IMPORT_ACTIVITIES_PARAMETERS parameters: IMPORT_ACTIVITIES_PARAMETERS
}) })

4
apps/api/src/app/endpoints/mcp/mcp.module.ts

@ -2,6 +2,7 @@ import { ImportModule } from '@ghostfolio/api/app/import/import.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { getMcpUserOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; 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';
@ -38,8 +39,9 @@ import { McpService } from './mcp.service';
return new McpStrategy({ return new McpStrategy({
instructions: instructions:
'Ghostfolio is a wealth management application. The tools read the portfolio and the watchlist of the user who granted the access and import activities into the portfolio. They give no quantity and no monetary value (except the unit price of an activity).', 'Ghostfolio is a wealth management application. The tools read the portfolio and the watchlist of the user who granted the access and import activities into the portfolio. They give no quantity and no monetary value (except the unit price of an activity). Only the tools which the permission of the access covers are listed. To import activities, the access needs the permission "Restricted view and manage".',
name: 'ghostfolio', name: 'ghostfolio',
resolveUser: getMcpUserOfBearerToken,
title: 'Ghostfolio', title: 'Ghostfolio',
transports: [ transports: [
new StreamableHttpTransport({ new StreamableHttpTransport({

2
apps/api/src/app/endpoints/tags/tags.controller.ts

@ -5,7 +5,7 @@ import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { isSystemTag } from '@ghostfolio/common/helper'; import { isSystemTag } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,

2
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts

@ -7,7 +7,7 @@ import { CreateWatchlistItemDto } from '@ghostfolio/common/dtos';
import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { scopes } from '@ghostfolio/common/scopes'; import { scopes } from '@ghostfolio/common/scopes';
import { ImpersonationContext } from '@ghostfolio/common/types'; import type { ImpersonationContext } from '@ghostfolio/common/types';
import { import {
Body, Body,

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

@ -773,6 +773,27 @@ describe('PortfolioService', () => {
).getSummary(args); ).getSummary(args);
}; };
function createExcludedActivity({
currency = 'CHF',
quantity,
symbol,
type,
unitPrice
}: Pick<Activity, 'quantity' | 'type' | 'unitPrice'> & {
currency?: string;
symbol: string;
}) {
return {
currency,
quantity,
type,
unitPrice,
account: { tags: [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] },
assetProfile: { currency, symbol, dataSource: DataSource.YAHOO },
tags: []
} as unknown as Activity;
}
function createPortfolioCalculator() { function createPortfolioCalculator() {
return { return {
getDividendInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)), getDividendInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
@ -791,9 +812,13 @@ describe('PortfolioService', () => {
beforeEach(() => { beforeEach(() => {
jest jest
.spyOn(activitiesService, 'getActivities') .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValue({ activities: [], count: 0 }); .mockResolvedValue({ activities: [], count: 0 });
jest
.spyOn(exchangeRateDataService, 'toCurrency')
.mockImplementation((aValue) => aValue);
jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({ jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({
performance: { performance: {
currentValueInBaseCurrency: 3000, currentValueInBaseCurrency: 3000,
@ -837,6 +862,177 @@ describe('PortfolioService', () => {
expect(summary.totalAssetsInBaseCurrency).toBe(3000); expect(summary.totalAssetsInBaseCurrency).toBe(3000);
expect(summary.totalValueInBaseCurrency).toBe(3000); expect(summary.totalValueInBaseCurrency).toBe(3000);
}); });
it('should value the open holdings of excluded accounts at the current market price', async () => {
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [],
balanceInBaseCurrency: 1500
});
jest
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValue({
activities: [
createExcludedActivity({
quantity: 10,
symbol: 'AAPL',
type: 'BUY',
unitPrice: 100
}),
createExcludedActivity({
quantity: 4,
symbol: 'AAPL',
type: 'SELL',
unitPrice: 150
}),
createExcludedActivity({
quantity: 5,
symbol: 'MSFT',
type: 'BUY',
unitPrice: 100
}),
createExcludedActivity({
quantity: 5,
symbol: 'MSFT',
type: 'SELL',
unitPrice: 120
})
],
count: 4
});
const getQuotes = jest
.spyOn(dataProviderService, 'getQuotes')
.mockResolvedValue({
'YAHOO-AAPL': {
currency: 'CHF',
dataSource: DataSource.YAHOO,
marketPrice: 200,
marketState: 'open'
}
});
const summary = await getSummary({
balanceInBaseCurrency: 1000,
emergencyFundHoldingsValueInBaseCurrency: 0,
filteredValueInBaseCurrency: new Big(3000),
portfolioCalculator: createPortfolioCalculator(),
userCurrency: 'CHF',
userId: userDummyData.id
});
// The closed holding (MSFT) does not need a quote
expect(getQuotes).toHaveBeenCalledWith(
expect.objectContaining({
items: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }]
})
);
// 500 (balance of excluded accounts) + 6 * 200 (AAPL) + 0 (MSFT)
expect(summary.excludedAccountsAndActivities).toBe(1700);
expect(summary.totalValueInBaseCurrency).toBe(4700);
});
it('should convert the market value of an excluded holding from the currency of the asset profile to the user currency', async () => {
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [],
balanceInBaseCurrency: 1000
});
jest
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValue({
activities: [
createExcludedActivity({
currency: 'USD',
quantity: 10,
symbol: 'AAPL',
type: 'BUY',
unitPrice: 100
}),
createExcludedActivity({
currency: 'USD',
quantity: 4,
symbol: 'AAPL',
type: 'SELL',
unitPrice: 150
})
],
count: 2
});
jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({
'YAHOO-AAPL': {
currency: 'USD',
dataSource: DataSource.YAHOO,
marketPrice: 200,
marketState: 'open'
}
});
jest
.spyOn(exchangeRateDataService, 'toCurrency')
.mockImplementation((aValue, aFromCurrency, aToCurrency) => {
return aFromCurrency === 'USD' && aToCurrency === 'CHF'
? aValue * 0.8
: aValue;
});
const summary = await getSummary({
balanceInBaseCurrency: 1000,
emergencyFundHoldingsValueInBaseCurrency: 0,
filteredValueInBaseCurrency: new Big(3000),
portfolioCalculator: createPortfolioCalculator(),
userCurrency: 'CHF',
userId: userDummyData.id
});
// 6 * 200 USD (AAPL) * 0.8 (USDCHF)
expect(summary.excludedAccountsAndActivities).toBe(960);
expect(summary.totalValueInBaseCurrency).toBe(3960);
});
it('should fall back to the unit price of the latest activity of an excluded holding without a market price', async () => {
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [],
balanceInBaseCurrency: 1000
});
jest
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValue({
activities: [
createExcludedActivity({
quantity: 10,
symbol: 'AAPL',
type: 'BUY',
unitPrice: 100
}),
createExcludedActivity({
quantity: 4,
symbol: 'AAPL',
type: 'SELL',
unitPrice: 150
})
],
count: 2
});
jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({});
const summary = await getSummary({
balanceInBaseCurrency: 1000,
emergencyFundHoldingsValueInBaseCurrency: 0,
filteredValueInBaseCurrency: new Big(3000),
portfolioCalculator: createPortfolioCalculator(),
userCurrency: 'CHF',
userId: userDummyData.id
});
// 6 * 150 (unit price of the latest activity)
expect(summary.excludedAccountsAndActivities).toBe(900);
expect(summary.totalValueInBaseCurrency).toBe(3900);
});
}); });
describe('getValueOfAccountsAndPlatforms', () => { describe('getValueOfAccountsAndPlatforms', () => {

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

@ -39,6 +39,7 @@ import {
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier, getAssetProfileIdentifier,
@ -2102,7 +2103,8 @@ export class PortfolioService {
}): Promise<PortfolioSummary> { }): Promise<PortfolioSummary> {
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const { activities } = await this.activitiesService.getActivities({ const { activities } =
await this.activitiesService.getActivitiesForPortfolioCalculator({
userCurrency, userCurrency,
userId, userId,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
@ -2174,31 +2176,28 @@ export class PortfolioService {
.plus(emergencyFundHoldingsValueInBaseCurrency) .plus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber(); .toNumber();
const totalOfExcludedActivities = this.getSumOfActivityType({ const [
userCurrency, cashDetailsWithExcludedAccounts,
activities: excludedActivities, valueOfExcludedActivitiesInBaseCurrency
activityType: 'BUY' ] = await Promise.all([
}).minus( this.accountService.getCashDetails({
this.getSumOfActivityType({
userCurrency,
activities: excludedActivities,
activityType: 'SELL'
})
);
const cashDetailsWithExcludedAccounts =
await this.accountService.getCashDetails({
userId, userId,
currency: userCurrency, currency: userCurrency,
withExcludedAccounts: true withExcludedAccounts: true
}); }),
this.getValueOfExcludedActivitiesInBaseCurrency({
userCurrency,
activities: excludedActivities,
subscriptionType: user.subscription?.type
})
]);
const excludedBalanceInBaseCurrency = new Big( const excludedBalanceInBaseCurrency = new Big(
cashDetailsWithExcludedAccounts.balanceInBaseCurrency cashDetailsWithExcludedAccounts.balanceInBaseCurrency
).minus(balanceInBaseCurrency); ).minus(balanceInBaseCurrency);
const excludedAccountsAndActivities = excludedBalanceInBaseCurrency const excludedAccountsAndActivities = excludedBalanceInBaseCurrency
.plus(totalOfExcludedActivities) .plus(valueOfExcludedActivitiesInBaseCurrency)
.toNumber(); .toNumber();
// Exclude emergency fund from the financial independence calculation // Exclude emergency fund from the financial independence calculation
@ -2502,6 +2501,92 @@ export class PortfolioService {
return { accounts, platforms }; return { accounts, platforms };
} }
private async getValueOfExcludedActivitiesInBaseCurrency({
activities,
subscriptionType,
userCurrency
}: {
activities: Activity[];
subscriptionType?: SubscriptionType;
userCurrency: string;
}) {
const holdings: {
[assetProfileIdentifier: string]: {
latestActivity: Activity;
quantity: Big;
};
} = {};
// The activities are sorted by date in ascending order
for (const activity of activities) {
const factor = getFactor(activity.type);
if (factor === 0 || isDraftActivity(activity)) {
continue;
}
const assetProfileIdentifier = getAssetProfileIdentifier(
activity.assetProfile
);
const quantity = holdings[assetProfileIdentifier]?.quantity ?? new Big(0);
holdings[assetProfileIdentifier] = {
latestActivity: activity,
quantity: quantity.plus(new Big(activity.quantity).mul(factor))
};
}
const openHoldings = Object.values(holdings).filter(({ quantity }) => {
return !quantity.eq(0);
});
const quotes =
openHoldings.length > 0
? await this.dataProviderService.getQuotes({
subscriptionType,
items: openHoldings.map(
({
latestActivity: {
assetProfile: { dataSource, symbol }
}
}) => {
return { dataSource, symbol };
}
)
})
: {};
return getSum(
openHoldings.map(({ latestActivity, quantity }) => {
const { assetProfile, currency, unitPrice } = latestActivity;
const marketPrice =
quotes[getAssetProfileIdentifier(assetProfile)]?.marketPrice;
if (!marketPrice) {
// Fall back to the unit price of the latest activity without a
// market price
return new Big(
this.exchangeRateDataService.toCurrency(
quantity.mul(unitPrice).toNumber(),
currency ?? assetProfile.currency,
userCurrency
)
);
}
return new Big(
this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice).toNumber(),
assetProfile.currency,
userCurrency
)
);
})
);
}
private isExcludedFromAnalysis(activity: Activity) { private isExcludedFromAnalysis(activity: Activity) {
return ( return (
isAccountExcluded(activity.account) || isAccountExcluded(activity.account) ||

38
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json

@ -459,7 +459,7 @@
"AHARWBTC": "Aave Harmony WBTC", "AHARWBTC": "Aave Harmony WBTC",
"AHOO": "Ahoolee", "AHOO": "Ahoolee",
"AHT": "AhaToken", "AHT": "AhaToken",
"AI": "Flourishing AI", "AI": "Gensyn",
"AI16Z": "ai16z", "AI16Z": "ai16z",
"AI21X": "ai21x", "AI21X": "ai21x",
"AI23T": "23 Turtles", "AI23T": "23 Turtles",
@ -467,7 +467,7 @@
"AI3": "Autonomys Network", "AI3": "Autonomys Network",
"AI4": "AI⁴", "AI4": "AI⁴",
"AI69SAKURA": "Sakura", "AI69SAKURA": "Sakura",
"AIA": "AIA Chain", "AIA": "DeAgentAI",
"AIACHAIN": "AIA Chain", "AIACHAIN": "AIA Chain",
"AIAF": "AI Agent Factory", "AIAF": "AI Agent Factory",
"AIAGENT": "AI Agents", "AIAGENT": "AI Agents",
@ -1338,7 +1338,6 @@
"AUX": "Auxilium", "AUX": "Auxilium",
"AV": "AvatarCoin", "AV": "AvatarCoin",
"AVA": "AVA", "AVA": "AVA",
"AVA34326": "AVA Chiang Mai Night Safari",
"AVAAI": "Ava AI", "AVAAI": "Ava AI",
"AVACN": "AVACOIN", "AVACN": "AVACOIN",
"AVAI": "Orca AVAI", "AVAI": "Orca AVAI",
@ -2066,7 +2065,7 @@
"BHIGH": "Backed HIGH € High Yield Corp Bond", "BHIGH": "Backed HIGH € High Yield Corp Bond",
"BHIRE": "BitHIRE", "BHIRE": "BitHIRE",
"BHIVE": "Hive", "BHIVE": "Hive",
"BHO": "Bholdus", "BHO": "Bholdus Token",
"BHP": "Blockchain of Hash Power", "BHP": "Blockchain of Hash Power",
"BHPC": "BHPCash", "BHPC": "BHPCash",
"BIAFRA": "Biafra Coin", "BIAFRA": "Biafra Coin",
@ -3064,6 +3063,7 @@
"BTU": "BTU Protocol", "BTU": "BTU Protocol",
"BTV": "Bitvote", "BTV": "Bitvote",
"BTW": "BitWhite", "BTW": "BitWhite",
"BTW39158": "Bitway",
"BTX": "BitCore", "BTX": "BitCore",
"BTXC": "Bettex coin", "BTXC": "Bettex coin",
"BTXEX": "BTXEX", "BTXEX": "BTXEX",
@ -4055,7 +4055,6 @@
"COMAI": "Commune AI", "COMAI": "Commune AI",
"COMB": "Combine.finance", "COMB": "Combine.finance",
"COMBO": "COMBO", "COMBO": "COMBO",
"COMBO8259": "Furucombo",
"COMBOX": "ComBox", "COMBOX": "ComBox",
"COMC": "ComCrica Token", "COMC": "ComCrica Token",
"COME": "Community of Meme", "COME": "Community of Meme",
@ -4096,7 +4095,7 @@
"COOHA": "CoolHash", "COOHA": "CoolHash",
"COOK": "Cook Finance", "COOK": "Cook Finance",
"COOK33720": "mETH Protocol", "COOK33720": "mETH Protocol",
"COOKIE": "CookieSale", "COOKIE": "Cookie DAO",
"COOKTOKEN": "Cook", "COOKTOKEN": "Cook",
"COOL": "CoolCoin", "COOL": "CoolCoin",
"COOP": "Coop Network", "COOP": "Coop Network",
@ -8898,6 +8897,7 @@
"IMS": "Independent Money System", "IMS": "Independent Money System",
"IMST": "Imsmart", "IMST": "Imsmart",
"IMT": "Moneytoken", "IMT": "Moneytoken",
"IMU": "Immunefi",
"IMUSIFY": "imusify", "IMUSIFY": "imusify",
"IMVR": "ImmVRse", "IMVR": "ImmVRse",
"IMX": "Immutable", "IMX": "Immutable",
@ -9654,6 +9654,7 @@
"KINECOSYSTEM": "Kin", "KINECOSYSTEM": "Kin",
"KINET": "KinetixFi", "KINET": "KinetixFi",
"KING": "King Swap", "KING": "King Swap",
"KING33695": "King Protocol",
"KING93": "King93", "KING93": "King93",
"KINGB": "King Bean", "KINGB": "King Bean",
"KINGBONK": "King Bonk", "KINGBONK": "King Bonk",
@ -9981,7 +9982,6 @@
"LAELAPS": "Laelaps", "LAELAPS": "Laelaps",
"LAFFIN": "Laffin Kamala", "LAFFIN": "Laffin Kamala",
"LAI": "LayerAI", "LAI": "LayerAI",
"LAI23846": "LayerAI",
"LAIKA": "LAIKA", "LAIKA": "LAIKA",
"LAIKAPROTOCOL": "Laika Protocol", "LAIKAPROTOCOL": "Laika Protocol",
"LAINESOL": "Laine Staked SOL", "LAINESOL": "Laine Staked SOL",
@ -11576,7 +11576,8 @@
"MOMO": "Momo", "MOMO": "Momo",
"MOMO2": "MOMO 2.0", "MOMO2": "MOMO 2.0",
"MOMO2025": "momo", "MOMO2025": "momo",
"MON": "PocMon", "MON": "Monad",
"MON22337": "PocMon",
"MON30495": "Monad", "MON30495": "Monad",
"MON30950": "MON", "MON30950": "MON",
"MONA": "MonaCoin", "MONA": "MonaCoin",
@ -11645,7 +11646,6 @@
"MOOMEME": "MOO MOO", "MOOMEME": "MOO MOO",
"MOOMOO": "MOOMOO THE BULL", "MOOMOO": "MOOMOO THE BULL",
"MOON": "MoonSwap", "MOON": "MoonSwap",
"MOON7396": "r/CryptoCurrency Moons",
"MOONARCH": "Moonarch.app", "MOONARCH": "Moonarch.app",
"MOONB": "Moon Base", "MOONB": "Moon Base",
"MOONBEANS": "Moonbeans", "MOONBEANS": "Moonbeans",
@ -13604,7 +13604,8 @@
"PHV": "PATHHIVE", "PHV": "PATHHIVE",
"PHX": "Phoenix Global [old]", "PHX": "Phoenix Global [old]",
"PHY": "DePHY", "PHY": "DePHY",
"PI": "Plian", "PI": "Pi Network",
"PI2620": "Plian",
"PI35697": "Pi", "PI35697": "Pi",
"PIA": "Olympia AI", "PIA": "Olympia AI",
"PIAI": "Pi Network AI", "PIAI": "Pi Network AI",
@ -13830,7 +13831,7 @@
"PNDC": "Pond Coin", "PNDC": "Pond Coin",
"PNDN": "Pandana", "PNDN": "Pandana",
"PNDO": "Pondo", "PNDO": "Pondo",
"PNDR": "Pandora Protocol", "PNDR": "Pandora Finance",
"PNFT": "Pawn My NFT", "PNFT": "Pawn My NFT",
"PNG": "Pangolin", "PNG": "Pangolin",
"PNGDA": "Pengda Yellow Panda", "PNGDA": "Pengda Yellow Panda",
@ -14001,7 +14002,7 @@
"POWERMARKET": "POWER MARKET", "POWERMARKET": "POWER MARKET",
"POWR": "Powerledger", "POWR": "Powerledger",
"POWSCHE": "Powsche", "POWSCHE": "Powsche",
"POX": "Pollux Coin", "POX": "Monkey Pox",
"POZO": "Pozo Coin", "POZO": "Pozo Coin",
"PP": "Phoenix Protocol Dao", "PP": "Phoenix Protocol Dao",
"PPAD": "PlayPad", "PPAD": "PlayPad",
@ -14508,6 +14509,7 @@
"RAIL": "Railgun", "RAIL": "Railgun",
"RAILS": "Rails Token", "RAILS": "Rails Token",
"RAIN": "Rainmaker Games", "RAIN": "Rainmaker Games",
"RAIN38341": "Rain",
"RAINBOW": "Rainbow Token", "RAINBOW": "Rainbow Token",
"RAINBOWTOKEN": "Rainbow Token", "RAINBOWTOKEN": "Rainbow Token",
"RAINC": "RainCheck", "RAINC": "RainCheck",
@ -15930,7 +15932,8 @@
"SKULL": "Skull Order", "SKULL": "Skull Order",
"SKUY": "SEKUYA", "SKUY": "SEKUYA",
"SKX": "SKPANAX", "SKX": "SKPANAX",
"SKY": "Skycoin", "SKY": "Sky",
"SKY1619": "Skycoin",
"SKY33038": "Sky", "SKY33038": "Sky",
"SKYA": "Sekuya Multiverse", "SKYA": "Sekuya Multiverse",
"SKYAI": "SKYAI", "SKYAI": "SKYAI",
@ -18274,7 +18277,8 @@
"USDQ": "Quantoz USDQ", "USDQ": "Quantoz USDQ",
"USDQSTABLE": "USDQ", "USDQSTABLE": "USDQ",
"USDR": "Wrapped USDR", "USDR": "Wrapped USDR",
"USDS": "Stably Classic", "USDS": "USDS",
"USDS20039": "USDS",
"USDS33039": "USDS", "USDS33039": "USDS",
"USDSB": "USDSB", "USDSB": "USDSB",
"USDSTABLY": "StableUSD", "USDSTABLY": "StableUSD",
@ -18698,7 +18702,7 @@
"VPAY": "VPay by Virtuals", "VPAY": "VPay by Virtuals",
"VPK": "Vulture Peak", "VPK": "Vulture Peak",
"VPND": "VaporNodes", "VPND": "VaporNodes",
"VPP": "Virtue Poker", "VPP": "Virtue Poker Points",
"VPR": "VaporWallet", "VPR": "VaporWallet",
"VPRC": "VapersCoin", "VPRC": "VapersCoin",
"VPS": "VPS AI", "VPS": "VPS AI",
@ -18948,7 +18952,7 @@
"WDOT": "WDOT", "WDOT": "WDOT",
"WDR": "Wider Coin", "WDR": "Wider Coin",
"WDX": "WeiDex", "WDX": "WeiDex",
"WE": "Wanda Exchange", "WE": "WeBuy",
"WEALTH": "WealthCoin", "WEALTH": "WealthCoin",
"WEAPON": "MEGAWEAPON", "WEAPON": "MEGAWEAPON",
"WEAR": "MetaWear", "WEAR": "MetaWear",
@ -19116,7 +19120,6 @@
"WINB": "WINBIT CASINO", "WINB": "WINBIT CASINO",
"WINE": "Wine Shares", "WINE": "Wine Shares",
"WING": "Wing Finance", "WING": "Wing Finance",
"WING7048": "Wing Finance",
"WINGS": "Wings", "WINGS": "Wings",
"WINK": "Wink", "WINK": "Wink",
"WINN": "Winnerz", "WINN": "Winnerz",
@ -19687,7 +19690,6 @@
"XPLA": "XPLA", "XPLA": "XPLA",
"XPLL": "ParallelChain", "XPLL": "ParallelChain",
"XPM": "Primecoin", "XPM": "Primecoin",
"XPM20261": "XPMarket",
"XPN": "PANTHEON X", "XPN": "PANTHEON X",
"XPND": "Time Raiders", "XPND": "Time Raiders",
"XPNET": "XP NETWORK", "XPNET": "XP NETWORK",

11
apps/api/src/decorators/requires-scope-of-access.decorator.ts

@ -4,15 +4,24 @@ import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard';
import { Scope } from '@ghostfolio/common/scopes'; import { Scope } from '@ghostfolio/common/scopes';
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common'; import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { ToolScopes } from '@rekog/mcp-nest';
/** /**
* Marks a handler which requires the given scopes of an access, but no * Marks a handler which requires the given scopes of an access, but no
* authenticated user. The access itself is the credential, hence a client of * authenticated user. The access itself is the credential, hence a client of
* the model context protocol can use it. * the model context protocol can use it.
*
* The same scopes are declared to the transport of the model context
* protocol, which lists a tool only for an access whose scopes cover it. The
* transport refuses the call of a tool which it does not list with a protocol
* error before the guards and the filter of the exceptions run.
*/ */
export function RequiresScopeOfAccess(...requiredScopes: Scope[]) { export function RequiresScopeOfAccess(scope: Scope, ...otherScopes: Scope[]) {
const requiredScopes = [scope, ...otherScopes];
return applyDecorators( return applyDecorators(
SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes), SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes),
ToolScopes(requiredScopes),
UseGuards(AccessGuard, ScopeGuard) UseGuards(AccessGuard, ScopeGuard)
); );
} }

8
apps/api/src/guards/access.guard.ts

@ -1,3 +1,4 @@
import { getActiveImpersonationOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper';
import { getRequest } from '@ghostfolio/api/helper/execution-context.helper'; import { getRequest } from '@ghostfolio/api/helper/execution-context.helper';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -28,14 +29,17 @@ export class AccessGuard implements CanActivate {
public canActivate(context: ExecutionContext) { public canActivate(context: ExecutionContext) {
const request = getRequest<RequestWithUser>(context); const request = getRequest<RequestWithUser>(context);
if (!request?.impersonationOfBearerToken?.isActive) { const impersonationOfBearerToken =
getActiveImpersonationOfBearerToken(request);
if (!impersonationOfBearerToken) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
); );
} }
request.impersonation = request.impersonationOfBearerToken; request.impersonation = impersonationOfBearerToken;
return true; return true;
} }

31
apps/api/src/helper/bearer-token.helper.spec.ts

@ -0,0 +1,31 @@
import { scopes } from '@ghostfolio/common/scopes';
import { getMcpUserOfBearerToken } from './bearer-token.helper';
describe('getMcpUserOfBearerToken', () => {
it('should give the scopes of an active access', () => {
expect(
getMcpUserOfBearerToken({
impersonationOfBearerToken: {
isActive: true,
scopes: [scopes.portfolioRead]
}
})
).toEqual({ scopes: [scopes.portfolioRead] });
});
it('should give no user for an inactive access', () => {
expect(
getMcpUserOfBearerToken({
impersonationOfBearerToken: {
isActive: false,
scopes: [scopes.portfolioRead]
}
})
).toBeUndefined();
});
it('should give no user without an access', () => {
expect(getMcpUserOfBearerToken({})).toBeUndefined();
});
});

29
apps/api/src/helper/bearer-token.helper.ts

@ -1,3 +1,5 @@
import type { RequestWithUser } from '@ghostfolio/common/types';
const PREFIX_OF_BEARER_TOKEN = 'bearer '; const PREFIX_OF_BEARER_TOKEN = 'bearer ';
/** /**
@ -16,3 +18,30 @@ export function getAccessIdOfBearerToken(authorization?: string) {
? value.slice(PREFIX_OF_BEARER_TOKEN.length).trim() || undefined ? value.slice(PREFIX_OF_BEARER_TOKEN.length).trim() || undefined
: undefined; : undefined;
} }
/**
* Gives the context of the access which the authorization middleware of the
* model context protocol resolved from the bearer token, but only while the
* access is active
*/
export function getActiveImpersonationOfBearerToken(
request?: Pick<RequestWithUser, 'impersonationOfBearerToken'>
) {
return request?.impersonationOfBearerToken?.isActive
? request.impersonationOfBearerToken
: undefined;
}
/**
* Gives the user which the transport of the model context protocol evaluates
* to list and to call the tools. It carries the scopes of the active access,
* hence the transport lists only the tools which the access covers.
*/
export function getMcpUserOfBearerToken(request: unknown) {
const impersonationOfBearerToken =
getActiveImpersonationOfBearerToken(request);
return impersonationOfBearerToken
? { scopes: impersonationOfBearerToken.scopes }
: undefined;
}

38
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -48,7 +48,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.totalBuy" [value]="summary?.totalBuy"
/> />
</div> </div>
@ -62,7 +62,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.totalSell" [value]="summary?.totalSell"
/> />
</div> </div>
@ -79,7 +79,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.totalInvestmentValueWithCurrencyEffect" [value]="summary?.totalInvestmentValueWithCurrencyEffect"
/> />
</div> </div>
@ -93,7 +93,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.grossPerformanceWithCurrencyEffect" [value]="summary?.grossPerformanceWithCurrencyEffect"
/> />
</div> </div>
@ -110,7 +110,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.fees" [value]="summary?.fees"
/> />
</div> </div>
@ -127,7 +127,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.netPerformanceWithCurrencyEffect" [value]="summary?.netPerformanceWithCurrencyEffect"
/> />
</div> </div>
@ -171,7 +171,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.totalAssetsInBaseCurrency" [value]="summary?.totalAssetsInBaseCurrency"
/> />
</div> </div>
@ -217,7 +217,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="holdingsInBaseCurrency" [value]="holdingsInBaseCurrency"
/> />
</div> </div>
@ -233,7 +233,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="investmentsInBaseCurrency" [value]="investmentsInBaseCurrency"
/> />
</div> </div>
@ -248,7 +248,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.emergencyFund?.assets" [value]="summary?.emergencyFund?.assets"
/> />
</div> </div>
@ -295,7 +295,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.totalCashInBaseCurrency" [value]="summary?.totalCashInBaseCurrency"
/> />
</div> </div>
@ -311,7 +311,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.cash" [value]="summary?.cash"
/> />
</div> </div>
@ -326,7 +326,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.emergencyFund?.cash" [value]="summary?.emergencyFund?.cash"
/> />
</div> </div>
@ -356,7 +356,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.excludedAccountsAndActivities" [value]="summary?.excludedAccountsAndActivities"
/> />
</div> </div>
@ -379,7 +379,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.liabilitiesInBaseCurrency" [value]="summary?.liabilitiesInBaseCurrency"
/> />
</div> </div>
@ -396,7 +396,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.totalValueInBaseCurrency" [value]="summary?.totalValueInBaseCurrency"
/> />
</div> </div>
@ -469,7 +469,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.emergencyFund?.total" [value]="summary?.emergencyFund?.total"
/> />
</div> </div>
@ -483,7 +483,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.interestInBaseCurrency" [value]="summary?.interestInBaseCurrency"
/> />
</div> </div>
@ -497,7 +497,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="baseCurrency" [unit]="displayedCurrency"
[value]="summary?.dividendInBaseCurrency" [value]="summary?.dividendInBaseCurrency"
/> />
</div> </div>

4
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts

@ -79,6 +79,10 @@ export class GfPortfolioSummaryComponent implements OnChanges {
: 0; : 0;
} }
protected get displayedCurrency() {
return this.deviceType === 'mobile' ? undefined : this.baseCurrency;
}
protected get emergencyFundPercentage() { protected get emergencyFundPercentage() {
return this.summary?.totalValueInBaseCurrency return this.summary?.totalValueInBaseCurrency
? (this.summary.emergencyFund?.total || 0) / ? (this.summary.emergencyFund?.total || 0) /

2
apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html

@ -30,7 +30,7 @@
systems, including systems, including
<a href="https://github.com/bigbeartechworld/big-bear-casaos" <a href="https://github.com/bigbeartechworld/big-bear-casaos"
>CasaOS</a >CasaOS</a
>, >, <a href="https://easypanel.io/templates/ghostfolio">Easypanel</a>,
<a href="https://github.com/lildude/ha-addon-ghostfolio" <a href="https://github.com/lildude/ha-addon-ghostfolio"
>Home Assistant</a >Home Assistant</a
>, <a href="https://www.runtipi.io/docs/apps-available">Runtipi</a>, >, <a href="https://www.runtipi.io/docs/apps-available">Runtipi</a>,

60
apps/client/src/locales/messages.ca.xlf

@ -3,7 +3,7 @@
<body> <body>
<trans-unit id="659291745893337969" datatype="html"> <trans-unit id="659291745893337969" datatype="html">
<source>Invalid API key</source> <source>Invalid API key</source>
<target state="new">Invalid API key</target> <target state="translated">Clau API no vàlida</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context>
<context context-type="linenumber">115</context> <context context-type="linenumber">115</context>
@ -324,7 +324,7 @@
</trans-unit> </trans-unit>
<trans-unit id="251113330629316982" datatype="html"> <trans-unit id="251113330629316982" datatype="html">
<source>Date must be in the future.</source> <source>Date must be in the future.</source>
<target state="new">Date must be in the future.</target> <target state="translated">La data ha de ser futura.</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html</context>
<context context-type="linenumber">70</context> <context context-type="linenumber">70</context>
@ -332,7 +332,7 @@
</trans-unit> </trans-unit>
<trans-unit id="2522011507610634749" datatype="html"> <trans-unit id="2522011507610634749" datatype="html">
<source>Fetch market price</source> <source>Fetch market price</source>
<target state="new">Fetch market price</target> <target state="translated">Recupera el preu de mercat</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html</context> <context context-type="sourcefile">libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html</context>
<context context-type="linenumber">40</context> <context context-type="linenumber">40</context>
@ -372,7 +372,7 @@
</trans-unit> </trans-unit>
<trans-unit id="8264632476919693099" datatype="html"> <trans-unit id="8264632476919693099" datatype="html">
<source>Any person who has the link can view the data.</source> <source>Any person who has the link can view the data.</source>
<target state="new">Any person who has the link can view the data.</target> <target state="translated">Qualsevol persona que tingui l’enllaç pot veure les dades.</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html</context>
<context context-type="linenumber">42</context> <context context-type="linenumber">42</context>
@ -516,7 +516,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4323470180912194028" datatype="html"> <trans-unit id="4323470180912194028" datatype="html">
<source>Copy</source> <source>Copy</source>
<target state="new">Copy</target> <target state="translated">Copia</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.html</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.html</context>
<context context-type="linenumber">20</context> <context context-type="linenumber">20</context>
@ -696,7 +696,7 @@
</trans-unit> </trans-unit>
<trans-unit id="8282940047848889809" datatype="html"> <trans-unit id="8282940047848889809" datatype="html">
<source>Paid</source> <source>Paid</source>
<target state="new">Paid</target> <target state="translated">De pagament</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts</context> <context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts</context>
<context context-type="linenumber">136</context> <context context-type="linenumber">136</context>
@ -788,7 +788,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4467323362722952678" datatype="html"> <trans-unit id="4467323362722952678" datatype="html">
<source>Unknown</source> <source>Unknown</source>
<target state="new">Unknown</target> <target state="translated">Desconegut</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">89</context> <context context-type="linenumber">89</context>
@ -804,7 +804,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4472298507300372923" datatype="html"> <trans-unit id="4472298507300372923" datatype="html">
<source>View and manage</source> <source>View and manage</source>
<target state="new">View and manage</target> <target state="translated">Veure i gestionar</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/access-level-icon/access-level-icon.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/access-level-icon/access-level-icon.component.html</context>
<context context-type="linenumber">9</context> <context context-type="linenumber">9</context>
@ -904,7 +904,7 @@
</trans-unit> </trans-unit>
<trans-unit id="3973560112150137298" datatype="html"> <trans-unit id="3973560112150137298" datatype="html">
<source>Find an account...</source> <source>Find an account...</source>
<target state="new">Find an account...</target> <target state="translated">Cerca un compte...</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/assistant/assistant.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/assistant/assistant.component.ts</context>
<context context-type="linenumber">450</context> <context context-type="linenumber">450</context>
@ -984,7 +984,7 @@
</trans-unit> </trans-unit>
<trans-unit id="1806667489382256324" datatype="html"> <trans-unit id="1806667489382256324" datatype="html">
<source>Category</source> <source>Category</source>
<target state="new">Category</target> <target state="translated">Categoria</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">76</context> <context context-type="linenumber">76</context>
@ -1052,7 +1052,7 @@
</trans-unit> </trans-unit>
<trans-unit id="6418462810730461014" datatype="html"> <trans-unit id="6418462810730461014" datatype="html">
<source>Data Gathering Frequency</source> <source>Data Gathering Frequency</source>
<target state="new">Data Gathering Frequency</target> <target state="translated">Freqüència de recollida de dades</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html</context>
<context context-type="linenumber">477</context> <context context-type="linenumber">477</context>
@ -1088,7 +1088,7 @@
</trans-unit> </trans-unit>
<trans-unit id="6131560364436366828" datatype="html"> <trans-unit id="6131560364436366828" datatype="html">
<source>Apply current market price</source> <source>Apply current market price</source>
<target state="new">Apply current market price</target> <target state="translated">Aplica el preu de mercat actual</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">221</context> <context context-type="linenumber">221</context>
@ -1096,7 +1096,7 @@
</trans-unit> </trans-unit>
<trans-unit id="6135731497699355929" datatype="html"> <trans-unit id="6135731497699355929" datatype="html">
<source>Subscription History</source> <source>Subscription History</source>
<target state="new">Subscription History</target> <target state="translated">Historial de subscripcions</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html</context>
<context context-type="linenumber">156</context> <context context-type="linenumber">156</context>
@ -1144,7 +1144,7 @@
</trans-unit> </trans-unit>
<trans-unit id="7194017842579795231" datatype="html"> <trans-unit id="7194017842579795231" datatype="html">
<source>Healthcare</source> <source>Healthcare</source>
<target state="new">Healthcare</target> <target state="translated">Salut</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">98</context> <context context-type="linenumber">98</context>
@ -1276,7 +1276,7 @@
</trans-unit> </trans-unit>
<trans-unit id="5765523585863707029" datatype="html"> <trans-unit id="5765523585863707029" datatype="html">
<source>Technology</source> <source>Technology</source>
<target state="new">Technology</target> <target state="translated">Tecnologia</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">102</context> <context context-type="linenumber">102</context>
@ -1344,7 +1344,7 @@
</trans-unit> </trans-unit>
<trans-unit id="8319378030525016917" datatype="html"> <trans-unit id="8319378030525016917" datatype="html">
<source>Asset profile has been saved</source> <source>Asset profile has been saved</source>
<target state="new">Asset profile has been saved</target> <target state="translated">El perfil de l’actiu s’ha desat</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context> <context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context>
<context context-type="linenumber">754</context> <context context-type="linenumber">754</context>
@ -1512,7 +1512,7 @@
</trans-unit> </trans-unit>
<trans-unit id="8155924822307560615" datatype="html"> <trans-unit id="8155924822307560615" datatype="html">
<source>Update Account</source> <source>Update Account</source>
<target state="new">Update Account</target> <target state="translated">Actualitza el compte</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context> <context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
<context context-type="linenumber">120</context> <context context-type="linenumber">120</context>
@ -1644,7 +1644,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4340477809050781416" datatype="html"> <trans-unit id="4340477809050781416" datatype="html">
<source>Current year</source> <source>Current year</source>
<target state="new">Current year</target> <target state="translated">Any actual</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context> <context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context>
<context context-type="linenumber">267</context> <context context-type="linenumber">267</context>
@ -1660,7 +1660,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4344577155823592293" datatype="html"> <trans-unit id="4344577155823592293" datatype="html">
<source>Cache has been flushed.</source> <source>Cache has been flushed.</source>
<target state="new">Cache has been flushed.</target> <target state="translated">La memòria cau s’ha buidat.</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-overview/admin-overview.component.ts</context> <context context-type="sourcefile">apps/client/src/app/components/admin-overview/admin-overview.component.ts</context>
<context context-type="linenumber">281</context> <context context-type="linenumber">281</context>
@ -2080,7 +2080,7 @@
</trans-unit> </trans-unit>
<trans-unit id="1022809987579838138" datatype="html"> <trans-unit id="1022809987579838138" datatype="html">
<source>Last Used</source> <source>Last Used</source>
<target state="new">Last Used</target> <target state="translated">Últim ús</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/access-table/access-table.component.html</context> <context context-type="sourcefile">apps/client/src/app/components/access-table/access-table.component.html</context>
<context context-type="linenumber">31</context> <context context-type="linenumber">31</context>
@ -2184,7 +2184,7 @@
</trans-unit> </trans-unit>
<trans-unit id="6004588582437169024" datatype="html"> <trans-unit id="6004588582437169024" datatype="html">
<source>Current week</source> <source>Current week</source>
<target state="new">Current week</target> <target state="translated">Setmana actual</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context> <context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context>
<context context-type="linenumber">259</context> <context context-type="linenumber">259</context>
@ -2212,7 +2212,7 @@
</trans-unit> </trans-unit>
<trans-unit id="8186013988289067040" datatype="html"> <trans-unit id="8186013988289067040" datatype="html">
<source>Code</source> <source>Code</source>
<target state="new">Code</target> <target state="translated">Codi</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-overview/admin-overview.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-overview/admin-overview.html</context>
<context context-type="linenumber">165</context> <context context-type="linenumber">165</context>
@ -2312,7 +2312,7 @@
</trans-unit> </trans-unit>
<trans-unit id="273949409065292025" datatype="html"> <trans-unit id="273949409065292025" datatype="html">
<source>Energy</source> <source>Energy</source>
<target state="new">Energy</target> <target state="translated">Energia</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">96</context> <context context-type="linenumber">96</context>
@ -2676,7 +2676,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4037247308022519888" datatype="html"> <trans-unit id="4037247308022519888" datatype="html">
<source>The value has been copied to the clipboard</source> <source>The value has been copied to the clipboard</source>
<target state="new">The value has been copied to the clipboard</target> <target state="translated">El valor s’ha copiat al porta-retalls</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context> <context context-type="sourcefile">libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts</context>
<context context-type="linenumber">46</context> <context context-type="linenumber">46</context>
@ -2752,7 +2752,7 @@
</trans-unit> </trans-unit>
<trans-unit id="1259421956660976189" datatype="html"> <trans-unit id="1259421956660976189" datatype="html">
<source>Add Account</source> <source>Add Account</source>
<target state="new">Add Account</target> <target state="translated">Afegeix un compte</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context> <context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
<context context-type="linenumber">101</context> <context context-type="linenumber">101</context>
@ -2848,7 +2848,7 @@
</trans-unit> </trans-unit>
<trans-unit id="570056762949632344" datatype="html"> <trans-unit id="570056762949632344" datatype="html">
<source>Clone Activity</source> <source>Clone Activity</source>
<target state="new">Clone Activity</target> <target state="translated">Clona l’activitat</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context> <context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
<context context-type="linenumber">190</context> <context context-type="linenumber">190</context>
@ -2992,7 +2992,7 @@
</trans-unit> </trans-unit>
<trans-unit id="3885497195825665706" datatype="html"> <trans-unit id="3885497195825665706" datatype="html">
<source>Next</source> <source>Next</source>
<target state="new">Next</target> <target state="translated">Següent</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/resources/personal-finance-tools/product-page.html</context>
<context context-type="linenumber">406</context> <context context-type="linenumber">406</context>
@ -3148,7 +3148,7 @@
</trans-unit> </trans-unit>
<trans-unit id="2988589012964101797" datatype="html"> <trans-unit id="2988589012964101797" datatype="html">
<source>Daily</source> <source>Daily</source>
<target state="new">Daily</target> <target state="translated">Diàriament</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context> <context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context>
<context context-type="linenumber">247</context> <context context-type="linenumber">247</context>
@ -3849,7 +3849,7 @@
</trans-unit> </trans-unit>
<trans-unit id="2047393478951255414" datatype="html"> <trans-unit id="2047393478951255414" datatype="html">
<source>Creation</source> <source>Creation</source>
<target state="new">Creation</target> <target state="translated">Creació</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-overview/admin-overview.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-overview/admin-overview.html</context>
<context context-type="linenumber">191</context> <context context-type="linenumber">191</context>
@ -6758,7 +6758,7 @@
</trans-unit> </trans-unit>
<trans-unit id="1468015720862673946" datatype="html"> <trans-unit id="1468015720862673946" datatype="html">
<source>View Details</source> <source>View Details</source>
<target state="new">View Details</target> <target state="translated">Veure els detalls</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-users/admin-users.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-users/admin-users.html</context>
<context context-type="linenumber">220</context> <context context-type="linenumber">220</context>

1
libs/common/src/lib/interfaces/product.interface.ts

@ -17,6 +17,7 @@ export interface Product {
pricingPerYear?: string; pricingPerYear?: string;
regions?: string[]; regions?: string[];
slogan?: string; slogan?: string;
updatedAt?: string;
url?: string; url?: string;
useAnonymously?: boolean; useAnonymously?: boolean;
} }

1407
libs/common/src/lib/personal-finance-tools.ts

File diff suppressed because it is too large

1
libs/common/src/lib/types/product-category.type.ts

@ -4,6 +4,7 @@ export type ProductCategory =
| 'DIVIDEND_TRACKING' | 'DIVIDEND_TRACKING'
| 'ETF_TRACKING' | 'ETF_TRACKING'
| 'FAMILY_OFFICE' | 'FAMILY_OFFICE'
| 'FINANCIAL_ADVISOR'
| 'FINANCIAL_PLANNING' | 'FINANCIAL_PLANNING'
| 'INVESTMENT_RESEARCH' | 'INVESTMENT_RESEARCH'
| 'NET_WORTH_TRACKING' | 'NET_WORTH_TRACKING'

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

@ -50,13 +50,15 @@
<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>
<div class="align-items-center d-flex">
<gf-entity-logo <gf-entity-logo
class="d-inline d-sm-none mr-1" class="d-sm-none mr-1"
[hasPlaceholder]="true" [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>
</div>
</td> </td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td> <td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
</ng-container> </ng-container>

1
libs/ui/src/lib/i18n.ts

@ -107,6 +107,7 @@ const locales = {
DIVIDEND_TRACKING: $localize`Dividend Tracking`, DIVIDEND_TRACKING: $localize`Dividend Tracking`,
ETF_TRACKING: $localize`ETF Tracking`, ETF_TRACKING: $localize`ETF Tracking`,
FAMILY_OFFICE: $localize`Family Office`, FAMILY_OFFICE: $localize`Family Office`,
FINANCIAL_ADVISOR: $localize`Financial Advisor`,
FINANCIAL_PLANNING: $localize`Financial Planning`, FINANCIAL_PLANNING: $localize`Financial Planning`,
INVESTMENT_RESEARCH: $localize`Investment Research`, INVESTMENT_RESEARCH: $localize`Investment Research`,
NET_WORTH_TRACKING: $localize`Net Worth Tracking`, NET_WORTH_TRACKING: $localize`Net Worth Tracking`,

149
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.72.0", "version": "3.73.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.72.0", "version": "3.73.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -21,9 +21,9 @@
"@angular/platform-browser-dynamic": "22.1.2", "@angular/platform-browser-dynamic": "22.1.2",
"@angular/router": "22.1.2", "@angular/router": "22.1.2",
"@angular/service-worker": "22.1.2", "@angular/service-worker": "22.1.2",
"@bull-board/api": "9.9.0", "@bull-board/api": "9.10.1",
"@bull-board/express": "9.9.0", "@bull-board/express": "9.10.1",
"@bull-board/nestjs": "9.9.0", "@bull-board/nestjs": "9.10.1",
"@codewithdan/observable-store": "3.0.0", "@codewithdan/observable-store": "3.0.0",
"@date-fns/utc": "2.1.1", "@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.8", "@internationalized/number": "3.6.8",
@ -49,7 +49,7 @@
"@openrouter/ai-sdk-provider": "3.0.0", "@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.10.0", "@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"@rekog/mcp-nest": "2.0.2", "@rekog/mcp-nest": "2.0.7",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1", "@simplewebauthn/server": "13.3.1",
"ai": "7.0.37", "ai": "7.0.37",
@ -107,7 +107,7 @@
"undici": "8.10.0", "undici": "8.10.0",
"uuid": "14.0.2", "uuid": "14.0.2",
"yahoo-finance2": "4.0.2", "yahoo-finance2": "4.0.2",
"zod": "4.5.4", "zod": "4.6.5",
"zone.js": "0.16.3" "zone.js": "0.16.3"
}, },
"devDependencies": { "devDependencies": {
@ -6168,15 +6168,16 @@
"license": "(Apache-2.0 AND BSD-3-Clause)" "license": "(Apache-2.0 AND BSD-3-Clause)"
}, },
"node_modules/@bull-board/api": { "node_modules/@bull-board/api": {
"version": "9.9.0", "version": "9.10.1",
"resolved": "https://registry.npmjs.org/@bull-board/api/-/api-9.9.0.tgz", "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-9.10.1.tgz",
"integrity": "sha512-tL6rSqBjLkL5MbOwuf8/6W2jLDw+ZZpnqjzHAoerdQ2P8gkbgzhdZJHn48EvDWB/VtqAKLJdlCYafxiYy+NHNg==", "integrity": "sha512-yaFVInFvQAKHQwW7xxdoWQaC2xW1f8JOAZ5k6Zl8GQBTnlhm6UmeRQUr2Gb6wMgfl5v61p2eSupPow2CEvz0ng==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"redis-info": "^3.1.0" "redis-info": "^3.1.0",
"valibot": "^1.5.0"
}, },
"peerDependencies": { "peerDependencies": {
"@bull-board/ui": "9.9.0", "@bull-board/ui": "9.10.1",
"bull": "^4.16.5", "bull": "^4.16.5",
"bullmq": "^5.56.0 || ^6.0.0" "bullmq": "^5.56.0 || ^6.0.0"
}, },
@ -6189,14 +6190,28 @@
} }
} }
}, },
"node_modules/@bull-board/api/node_modules/valibot": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.5.0.tgz",
"integrity": "sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==",
"license": "MIT",
"peerDependencies": {
"typescript": ">=5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@bull-board/express": { "node_modules/@bull-board/express": {
"version": "9.9.0", "version": "9.10.1",
"resolved": "https://registry.npmjs.org/@bull-board/express/-/express-9.9.0.tgz", "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-9.10.1.tgz",
"integrity": "sha512-DRxm9IjpvVunKTaEYTputoM+qMUYZpptei0f0y5MuaA4mh8tbRRZjJKx53DJ98cw10SgJ3hDDbjmZ4u9ycHlJQ==", "integrity": "sha512-sUXrFAwbllDNGyieB51Xr7bFa+f7uPdWWPMcArROYlAXsZqrG0HyRPf81bpCX8ZnBGTUcbsEq9jSdRpEfbqm7g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bull-board/api": "9.9.0", "@bull-board/api": "9.10.1",
"@bull-board/ui": "9.9.0", "@bull-board/ui": "9.10.1",
"ejs": "^6.0.1", "ejs": "^6.0.1",
"express": "^5.2.1" "express": "^5.2.1"
} }
@ -6214,12 +6229,12 @@
} }
}, },
"node_modules/@bull-board/nestjs": { "node_modules/@bull-board/nestjs": {
"version": "9.9.0", "version": "9.10.1",
"resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-9.9.0.tgz", "resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-9.10.1.tgz",
"integrity": "sha512-L5p6cZDJCpt4/9EvL+o0ilq3flWsbfRFrqXbNVoGVNvHA3X4BqcN0V1LE5TLYfo+4wRYmtfSAOsKs/elLReuGg==", "integrity": "sha512-u63jkaco3CBaJuasHVrB1E7MHo6bwfdnn3A1hb3Sl7EKnhsh1QIiBUx8DQd6s/8S5rOUbYiXh0sCaJkShl5RhQ==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@bull-board/api": "^9.9.0", "@bull-board/api": "^9.10.1",
"@nestjs/bull-shared": "^10.0.0 || ^11.0.0 || ^12.0.0", "@nestjs/bull-shared": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0",
@ -6228,12 +6243,12 @@
} }
}, },
"node_modules/@bull-board/ui": { "node_modules/@bull-board/ui": {
"version": "9.9.0", "version": "9.10.1",
"resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-9.9.0.tgz", "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-9.10.1.tgz",
"integrity": "sha512-kgq/xBw+UEf/QUpqwjt3846qU0LCJQgzxNKy4+hhr3WPYZcpcd7V4By45fXbueBLJm3NKBEpskymRJS6HuVLjA==", "integrity": "sha512-1E0zbLaljf+Zhx3nDUotzsG2Dz7/4i72nWCxKkFwxfz65MKXfObo2mXWMuIDoXAeWSa3sm7hlPj3h3scts4DaQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bull-board/api": "9.9.0" "@bull-board/api": "9.10.1"
} }
}, },
"node_modules/@cacheable/utils": { "node_modules/@cacheable/utils": {
@ -13599,14 +13614,13 @@
} }
}, },
"node_modules/@rekog/mcp-nest": { "node_modules/@rekog/mcp-nest": {
"version": "2.0.2", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@rekog/mcp-nest/-/mcp-nest-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@rekog/mcp-nest/-/mcp-nest-2.0.7.tgz",
"integrity": "sha512-kuye6wmRk5Gw4KZSCSXd83cWznRw4eFbvNUXsd8+1dYcS4uWKVVbRvxQIKNCyxc2UcljpsmLpnksr1NCRn3YAQ==", "integrity": "sha512-8DWrSapEA9Z/YJL9dFmj7sSbemmeQmf0VkGkW6Sn1itwKFf0n9hzTiwWt8XBtZvV5JVDNCtvRZKYYKgNjDcYPg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"multer": "^2.2.0", "multer": "^2.3.0",
"path-to-regexp": "^8.4.2", "path-to-regexp": "^8.4.2"
"rxjs": "^7.8.2"
}, },
"peerDependencies": { "peerDependencies": {
"@modelcontextprotocol/core": "^2.0.0-beta.5", "@modelcontextprotocol/core": "^2.0.0-beta.5",
@ -13615,9 +13629,10 @@
"@nestjs/common": ">=9.0.0", "@nestjs/common": ">=9.0.0",
"@nestjs/core": ">=9.0.0", "@nestjs/core": ">=9.0.0",
"@nestjs/microservices": ">=9.0.0", "@nestjs/microservices": ">=9.0.0",
"@nestjs/platform-fastify": "^11.1.5", "@nestjs/platform-fastify": "^11.1.5 || ^12.0.0",
"express": ">=4.0.0", "express": ">=4.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.1.0",
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
@ -13626,13 +13641,65 @@
} }
} }
}, },
"node_modules/@rekog/mcp-nest/node_modules/rxjs": { "node_modules/@rekog/mcp-nest/node_modules/media-typer": {
"version": "7.8.2", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "Apache-2.0", "license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@rekog/mcp-nest/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@rekog/mcp-nest/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@rekog/mcp-nest/node_modules/multer": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@rekog/mcp-nest/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
@ -37272,9 +37339,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.5.4", "version": "4.6.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
"integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

12
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.72.0", "version": "3.73.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",
@ -65,9 +65,9 @@
"@angular/platform-browser-dynamic": "22.1.2", "@angular/platform-browser-dynamic": "22.1.2",
"@angular/router": "22.1.2", "@angular/router": "22.1.2",
"@angular/service-worker": "22.1.2", "@angular/service-worker": "22.1.2",
"@bull-board/api": "9.9.0", "@bull-board/api": "9.10.1",
"@bull-board/express": "9.9.0", "@bull-board/express": "9.10.1",
"@bull-board/nestjs": "9.9.0", "@bull-board/nestjs": "9.10.1",
"@codewithdan/observable-store": "3.0.0", "@codewithdan/observable-store": "3.0.0",
"@date-fns/utc": "2.1.1", "@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.8", "@internationalized/number": "3.6.8",
@ -93,7 +93,7 @@
"@openrouter/ai-sdk-provider": "3.0.0", "@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.10.0", "@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"@rekog/mcp-nest": "2.0.2", "@rekog/mcp-nest": "2.0.7",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1", "@simplewebauthn/server": "13.3.1",
"ai": "7.0.37", "ai": "7.0.37",
@ -151,7 +151,7 @@
"undici": "8.10.0", "undici": "8.10.0",
"uuid": "14.0.2", "uuid": "14.0.2",
"yahoo-finance2": "4.0.2", "yahoo-finance2": "4.0.2",
"zod": "4.5.4", "zod": "4.6.5",
"zone.js": "0.16.3" "zone.js": "0.16.3"
}, },
"devDependencies": { "devDependencies": {

Loading…
Cancel
Save