Browse Source

Merge branch 'main' into feature/add-platform-logo-to-platform-selector-in-create-or-update-account-dialog

pull/7528/head
Thomas Kaul 4 weeks ago
committed by GitHub
parent
commit
e0a1317b9e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 14
      CHANGELOG.md
  2. 12
      apps/api/src/app/activities/activities.service.ts
  3. 10
      apps/api/src/app/admin/admin.service.ts
  4. 140
      apps/api/src/app/import/import.service.ts
  5. 30
      apps/api/src/app/portfolio/portfolio.service.ts
  6. 8
      apps/api/src/app/user/user.service.ts
  7. 2
      apps/api/src/services/data-provider/data-provider.service.ts
  8. 21
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  9. 38
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  10. 13
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss
  11. 25
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  12. 13
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  13. 3
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  14. 59
      apps/client/src/app/services/import-activities.service.ts
  15. 31
      libs/common/src/lib/helper.spec.ts
  16. 15
      libs/common/src/lib/helper.ts
  17. 2
      libs/common/src/lib/interfaces/portfolio-position.interface.ts
  18. 5
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.util.ts
  19. 38
      package-lock.json
  20. 3
      package.json
  21. 27
      test/import/not-ok/invalid-symbol-with-manual-data-source.json
  22. 24
      test/import/ok/without-accounts.json

14
CHANGELOG.md

@ -11,6 +11,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added the platform logo to the platform selector in the create or update account dialog - Added the platform logo to the platform selector in the create or update account dialog
## 3.42.0 - 2026-08-04
### Changed
- Improved the usability of the portfolio summary by collapsing the _Holdings_ and _Cash_ breakdowns by default
- Extended the support of the _Exclude from Analysis_ tag from accounts to activities
- Optimized the performance of the search in the assistant by reusing the cached portfolio snapshot
- Improved the validation of the import functionality when referencing an asset profile with the data source `MANUAL`
- Improved the validation of the endpoint to add a custom asset profile in the admin control panel
### Fixed
- Fixed the fuzzy search for the holdings in the assistant
## 3.41.0 - 2026-08-03 ## 3.41.0 - 2026-08-03
### Added ### Added

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

@ -20,12 +20,12 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
ghostfolioPrefix,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
getAssetProfileIdentifier getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse, ActivitiesResponse,
@ -48,7 +48,6 @@ import {
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -204,10 +203,9 @@ export class ActivitiesService {
let symbol: string; let symbol: string;
if ( if (
data.SymbolProfile.connectOrCreate.create.symbol.startsWith( isValidCustomAssetProfileSymbol(
`${ghostfolioPrefix}_` data.SymbolProfile.connectOrCreate.create.symbol
) || )
isUUID(data.SymbolProfile.connectOrCreate.create.symbol)
) { ) {
// Connect custom asset profile (clone) // Connect custom asset profile (clone)
symbol = data.SymbolProfile.connectOrCreate.create.symbol; symbol = data.SymbolProfile.connectOrCreate.create.symbol;

10
apps/api/src/app/admin/admin.service.ts

@ -7,6 +7,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
ghostfolioPrefix,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
@ -14,7 +15,8 @@ import {
import { import {
applyAssetProfileOverrides, applyAssetProfileOverrides,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCurrencyFromSymbol getCurrencyFromSymbol,
hasGhostfolioPrefix
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
@ -63,6 +65,12 @@ export class AdminService {
> { > {
try { try {
if (dataSource === 'MANUAL') { if (dataSource === 'MANUAL') {
if (!hasGhostfolioPrefix(symbol)) {
throw new BadRequestException(
`symbol ("${symbol}") must start with the prefix "${ghostfolioPrefix}_" for the data source ("${dataSource}")`
);
}
return this.symbolProfileService.add({ return this.symbolProfileService.add({
currency, currency,
dataSource, dataSource,

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

@ -11,11 +11,13 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/sy
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
ghostfolioPrefix,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos'; import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -196,6 +198,41 @@ export class ImportService {
const tagIdMapping: { [oldTagId: string]: string } = {}; const tagIdMapping: { [oldTagId: string]: string } = {};
const userCurrency = user.settings.settings.baseCurrency; const userCurrency = user.settings.settings.baseCurrency;
// Validate the symbols before any data is persisted
for (const [index, assetProfileWithMarketData] of (
assetProfilesWithMarketDataDto ?? []
).entries()) {
if (
assetProfileWithMarketData.dataSource === DataSource.MANUAL &&
!isValidCustomAssetProfileSymbol(assetProfileWithMarketData.symbol)
) {
throw new Error(
`assetProfiles.${index}.symbol ("${assetProfileWithMarketData.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")`
);
}
}
// Validate the symbols before any data is persisted. Activities without a
// data source are excluded, since a symbol is generated in
// createActivity() if needed.
for (const [index, activity] of activitiesDto.entries()) {
if (!activity.dataSource) {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) {
activity.dataSource = DataSource.MANUAL;
} else {
activity.dataSource =
this.dataProviderService.getDataSourceForImport();
}
} else if (
activity.dataSource === DataSource.MANUAL &&
!isValidCustomAssetProfileSymbol(activity.symbol)
) {
throw new Error(
`activities.${index}.symbol ("${activity.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")`
);
}
}
if (platformsDto?.length) { if (platformsDto?.length) {
const canCreatePlatform = hasPermission( const canCreatePlatform = hasPermission(
user.permissions, user.permissions,
@ -384,39 +421,77 @@ export class ImportService {
} }
} }
if (!isDryRun && assetProfilesWithMarketDataDto?.length) { if (assetProfilesWithMarketDataDto?.length) {
const existingAssetProfiles = const customAssetProfileNames = assetProfilesWithMarketDataDto
await this.symbolProfileService.getSymbolProfiles( .filter(({ dataSource, name }) => {
return dataSource === DataSource.MANUAL && Boolean(name);
})
.map(({ name }) => {
return name;
});
const [existingAssetProfiles, existingCustomAssetProfilesOfUser] =
await Promise.all([
this.symbolProfileService.getSymbolProfiles(
assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => { assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}) })
); ),
this.symbolProfileService.getCustomSymbolProfilesByNames({
names: customAssetProfileNames,
userId: user.id
})
]);
for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) {
let symbol = assetProfileWithMarketData.symbol;
// Check if there is any existing asset profile // Check if there is any existing asset profile
const existingAssetProfile = existingAssetProfiles.find( const existingAssetProfile = existingAssetProfiles.find(
({ dataSource, symbol }) => { (assetProfile) => {
return ( return (
dataSource === assetProfileWithMarketData.dataSource && assetProfile.dataSource ===
symbol === assetProfileWithMarketData.symbol assetProfileWithMarketData.dataSource &&
assetProfile.symbol === assetProfileWithMarketData.symbol
); );
} }
); );
// If there is no asset profile or if the asset profile belongs to a different user, then create a new asset profile // If there is no asset profile or if the asset profile belongs to a
// different user, then reuse the custom asset profile of the user or
// create a new asset profile
if (!existingAssetProfile || existingAssetProfile.userId !== user.id) { if (!existingAssetProfile || existingAssetProfile.userId !== user.id) {
// Check if the user has a custom asset profile with the same name.
// Skip asset profiles with a legacy free-text symbol as they would
// fail the symbol validation on a future import.
const existingCustomAssetProfileOfUser =
assetProfileWithMarketData.dataSource === DataSource.MANUAL
? existingCustomAssetProfilesOfUser.find((customAssetProfile) => {
return (
customAssetProfile.name ===
assetProfileWithMarketData.name &&
isValidCustomAssetProfileSymbol(customAssetProfile.symbol)
);
})
: undefined;
if (existingCustomAssetProfileOfUser) {
// Reuse the custom asset profile of the user instead of creating a duplicate
symbol = existingCustomAssetProfileOfUser.symbol;
} else {
const assetProfile: CreateAssetProfileDto = omit( const assetProfile: CreateAssetProfileDto = omit(
assetProfileWithMarketData, assetProfileWithMarketData,
'marketData' 'marketData'
); );
// Asset profile belongs to a different user // Asset profile belongs to a different user, generate a new symbol
if (existingAssetProfile) { if (existingAssetProfile && !isDryRun) {
const symbol = randomUUID(); symbol = randomUUID();
assetProfileSymbolMapping[assetProfile.symbol] = symbol;
assetProfile.symbol = symbol;
} }
assetProfile.symbol = symbol;
if (!isDryRun) {
// Create a new asset profile // Create a new asset profile
const assetProfileObject: Prisma.SymbolProfileCreateInput = { const assetProfileObject: Prisma.SymbolProfileCreateInput = {
...assetProfile, ...assetProfile,
@ -425,30 +500,38 @@ export class ImportService {
await this.symbolProfileService.add(assetProfileObject); await this.symbolProfileService.add(assetProfileObject);
} }
}
if (symbol !== assetProfileWithMarketData.symbol) {
assetProfileSymbolMapping[assetProfileWithMarketData.symbol] =
symbol;
// Keep the asset profile in sync with the activities to validate
assetProfileWithMarketData.symbol = symbol;
}
}
if (!isDryRun) {
// Insert or update market data // Insert or update market data
const marketDataObjects = assetProfileWithMarketData.marketData.map( const marketDataObjects = (
(marketData) => { assetProfileWithMarketData.marketData ?? []
).map((marketData) => {
return { return {
...marketData, ...marketData,
dataSource: assetProfileWithMarketData.dataSource, symbol,
symbol: assetProfileWithMarketData.symbol dataSource: assetProfileWithMarketData.dataSource
} as Prisma.MarketDataUpdateInput; } as Prisma.MarketDataUpdateInput;
} });
);
await this.marketDataService.updateMany({ data: marketDataObjects }); await this.marketDataService.updateMany({ data: marketDataObjects });
} }
} }
}
for (const activity of activitiesDto) { for (const activity of activitiesDto) {
if (!activity.dataSource) { // If an asset profile is created or reused, then update the symbol in all activities
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { if (assetProfileSymbolMapping[activity.symbol]) {
activity.dataSource = DataSource.MANUAL; activity.symbol = assetProfileSymbolMapping[activity.symbol];
} else {
activity.dataSource =
this.dataProviderService.getDataSourceForImport();
}
} }
if (!isDryRun) { if (!isDryRun) {
@ -457,11 +540,6 @@ export class ImportService {
activity.accountId = accountIdMapping[activity.accountId]; activity.accountId = accountIdMapping[activity.accountId];
} }
// If a new asset profile is created, then update the symbol in all activities
if (assetProfileSymbolMapping[activity.symbol]) {
activity.symbol = assetProfileSymbolMapping[activity.symbol];
}
// If a new tag is created, then update the tag ID in all activities // If a new tag is created, then update the tag ID in all activities
activity.tags = (activity.tags ?? []).map((tagId) => { activity.tags = (activity.tags ?? []).map((tagId) => {
return tagIdMapping[tagId] ?? tagId; return tagIdMapping[tagId] ?? tagId;

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

@ -165,6 +165,10 @@ export class PortfolioService {
}; };
} }
const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => {
return type !== 'SEARCH_QUERY';
});
const [accounts, details, user] = await Promise.all([ const [accounts, details, user] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
where, where,
@ -176,8 +180,8 @@ export class PortfolioService {
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getDetails({ this.getDetails({
filters,
withExcludedAccounts, withExcludedAccounts,
filters: filtersWithoutSearchQueryFilter,
impersonationId: userId, impersonationId: userId,
userId: this.request.user.id userId: this.request.user.id
}), }),
@ -369,14 +373,6 @@ export class PortfolioService {
userId: string; userId: string;
}) { }) {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const { holdings: holdingsMap } = await this.getDetails({
dateRange,
filters,
impersonationId,
userId
});
let holdings = Object.values(holdingsMap);
const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy( const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy(
filters, filters,
@ -385,9 +381,22 @@ export class PortfolioService {
} }
); );
const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => {
return type !== 'SEARCH_QUERY';
});
const { holdings: holdingsMap } = await this.getDetails({
dateRange,
impersonationId,
userId,
filters: filtersWithoutSearchQueryFilter
});
let holdings = Object.values(holdingsMap);
if (filterBySearchQuery) { if (filterBySearchQuery) {
const fuse = new Fuse(holdings, { const fuse = new Fuse(holdings, {
keys: ['isin', 'name', 'symbol'], keys: ['assetProfile.isin', 'assetProfile.name', 'assetProfile.symbol'],
threshold: 0.3 threshold: 0.3
}); });
@ -651,6 +660,7 @@ export class PortfolioService {
}; };
} }
), ),
isin: assetProfile.isin,
name: assetProfile.name, name: assetProfile.name,
sectors: assetProfile.sectors, sectors: assetProfile.sectors,
symbol: assetProfile.symbol, symbol: assetProfile.symbol,

8
apps/api/src/app/user/user.service.ts

@ -186,15 +186,15 @@ export class UserService {
systemMessage = systemMessageProperty; systemMessage = systemMessageProperty;
} }
let tags = tagsForUser.filter((tag) => { let tags = tagsForUser;
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === SubscriptionType.Basic subscription.type === SubscriptionType.Basic
) { ) {
tags = []; tags = tags.filter(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
} }
return { return {

2
apps/api/src/services/data-provider/data-provider.service.ts

@ -318,7 +318,7 @@ export class DataProviderService implements OnModuleInit {
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")` `${activityPath}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")`
); );
} }

21
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -105,6 +105,27 @@ export class SymbolProfileService {
}; };
} }
public async getCustomSymbolProfilesByNames({
names,
userId
}: {
names: string[];
userId: string;
}): Promise<Pick<SymbolProfile, 'name' | 'symbol'>[]> {
if (names.length === 0) {
return [];
}
return this.prismaService.symbolProfile.findMany({
select: { name: true, symbol: true },
where: {
userId,
dataSource: DataSource.MANUAL,
name: { in: names }
}
});
}
public async getSymbolProfiles( public async getSymbolProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedAssetProfile[]> { ): Promise<EnhancedAssetProfile[]> {

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

@ -164,7 +164,22 @@
</div> </div>
</div> </div>
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="align-items-center d-flex flex-grow-1 indent-1"> <div
class="align-items-center d-flex flex-grow-1 indent-1"
[class.cursor-pointer]="hasHoldingsBreakdown"
(click)="onToggleHoldings()"
>
@if (hasHoldingsBreakdown) {
<span
class="align-items-center caret-container d-inline-flex justify-content-center ml-n3"
>
<ion-icon
class="caret text-muted"
name="caret-forward-outline"
[class.caret-expanded]="isHoldingsExpanded"
/>
</span>
}
<ng-container i18n>Holdings</ng-container> <ng-container i18n>Holdings</ng-container>
@if ( @if (
!hasImpersonationId && !hasImpersonationId &&
@ -192,7 +207,7 @@
/> />
</div> </div>
</div> </div>
@if (isLoading || summary?.emergencyFund?.assets > 0) { @if (hasHoldingsBreakdown && isHoldingsExpanded) {
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 indent-2 text-truncate" i18n>Investments</div> <div class="flex-grow-1 indent-2 text-truncate" i18n>Investments</div>
<div class="flex-column flex-wrap justify-content-end"> <div class="flex-column flex-wrap justify-content-end">
@ -223,7 +238,22 @@
</div> </div>
} }
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="align-items-center d-flex flex-grow-1 indent-1"> <div
class="align-items-center d-flex flex-grow-1 indent-1"
[class.cursor-pointer]="hasCashBreakdown"
(click)="onToggleCash()"
>
@if (hasCashBreakdown) {
<span
class="align-items-center caret-container d-inline-flex justify-content-center ml-n3"
>
<ion-icon
class="caret text-muted"
name="caret-forward-outline"
[class.caret-expanded]="isCashExpanded"
/>
</span>
}
<ng-container i18n>Cash</ng-container> <ng-container i18n>Cash</ng-container>
@if ( @if (
!hasImpersonationId && !hasImpersonationId &&
@ -251,7 +281,7 @@
/> />
</div> </div>
</div> </div>
@if (isLoading || summary?.emergencyFund?.cash > 0) { @if (hasCashBreakdown && isCashExpanded) {
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 indent-2 text-truncate" i18n>Buying Power</div> <div class="flex-grow-1 indent-2 text-truncate" i18n>Buying Power</div>
<div class="flex-column flex-wrap justify-content-end"> <div class="flex-column flex-wrap justify-content-end">

13
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.scss

@ -1,6 +1,19 @@
:host { :host {
display: block; display: block;
.caret-container {
width: 1rem;
.caret {
font-size: 0.7rem;
transition: transform 150ms ease-in-out;
&.caret-expanded {
transform: rotate(90deg);
}
}
}
.indent-1 { .indent-1 {
margin-left: 1rem; margin-left: 1rem;
} }

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

@ -18,6 +18,7 @@ import { IonIcon } from '@ionic/angular/standalone';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
caretForwardOutline,
ellipsisHorizontalCircleOutline, ellipsisHorizontalCircleOutline,
informationCircleOutline informationCircleOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
@ -47,13 +48,19 @@ export class GfPortfolioSummaryComponent implements OnChanges {
'BUY_AND_SELL_ACTIVITIES_TOOLTIP' 'BUY_AND_SELL_ACTIVITIES_TOOLTIP'
); );
protected isCashExpanded = false;
protected isHoldingsExpanded = false;
protected precision = 2; protected precision = 2;
protected timeInMarket: string | undefined; protected timeInMarket: string | undefined;
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
public constructor() { public constructor() {
addIcons({ ellipsisHorizontalCircleOutline, informationCircleOutline }); addIcons({
caretForwardOutline,
ellipsisHorizontalCircleOutline,
informationCircleOutline
});
} }
protected get cashPercentage() { protected get cashPercentage() {
@ -77,6 +84,14 @@ export class GfPortfolioSummaryComponent implements OnChanges {
: 0; : 0;
} }
protected get hasCashBreakdown() {
return !this.isLoading && this.summary?.emergencyFund?.cash > 0;
}
protected get hasHoldingsBreakdown() {
return !this.isLoading && this.summary?.emergencyFund?.assets > 0;
}
protected get holdingsInBaseCurrency() { protected get holdingsInBaseCurrency() {
if ( if (
!isNumber(this.summary?.totalAssetsInBaseCurrency) || !isNumber(this.summary?.totalAssetsInBaseCurrency) ||
@ -145,4 +160,12 @@ export class GfPortfolioSummaryComponent implements OnChanges {
title: $localize`Please set the amount of your emergency fund.` title: $localize`Please set the amount of your emergency fund.`
}); });
} }
protected onToggleCash() {
this.isCashExpanded = !this.isCashExpanded;
}
protected onToggleHoldings() {
this.isHoldingsExpanded = !this.isHoldingsExpanded;
}
} }

13
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -1,5 +1,4 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config';
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos';
import { getStringOrNull } from '@ghostfolio/common/helper'; import { getStringOrNull } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -92,19 +91,13 @@ export class GfCreateOrUpdateAccountDialogComponent {
permissions.createOwnTag permissions.createOwnTag
); );
this.tagsAvailable = [ this.tagsAvailable =
...(this.data.user?.tags ?? []), this.data.user?.tags?.map((tag) => {
{
id: TAG_ID_EXCLUDE_FROM_ANALYSIS,
name: 'EXCLUDE_FROM_ANALYSIS',
userId: null
}
].map((tag) => {
return { return {
...tag, ...tag,
name: translate(tag.name) name: translate(tag.name)
}; };
}); }) ?? [];
this.accountForm = this.formBuilder.group({ this.accountForm = this.formBuilder.group({
accountId: [{ disabled: true, value: this.data.account.id }], accountId: [{ disabled: true, value: this.data.account.id }],

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

@ -95,7 +95,7 @@ export class GfAllocationsPageComponent implements OnInit {
| 'assetSubClassLabel' | 'assetSubClassLabel'
| 'currency' | 'currency'
| 'name' | 'name'
> & { etfProvider: string; exchange?: string; value: number }; > & { etfProvider: string; value: number };
}; };
protected isLoading = false; protected isLoading = false;
protected markets: PortfolioDetails['markets']; protected markets: PortfolioDetails['markets'];
@ -381,7 +381,6 @@ export class GfAllocationsPageComponent implements OnInit {
assetSubClass: position.assetProfile.assetSubClass, assetSubClass: position.assetProfile.assetSubClass,
name: position.assetProfile.name name: position.assetProfile.name
}), }),
exchange: position.exchange,
name: position.assetProfile.name, name: position.assetProfile.name,
value: this.showValuesInPercentage() value: this.showValuesInPercentage()
? position.allocationInPercentage ? position.allocationInPercentage

59
apps/client/src/app/services/import-activities.service.ts

@ -5,7 +5,10 @@ import {
CreatePlatformDto, CreatePlatformDto,
CreateTagDto CreateTagDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { parseDate as parseDateHelper } from '@ghostfolio/common/helper'; import {
isValidCustomAssetProfileSymbol,
parseDate as parseDateHelper
} from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces'; import { Activity } from '@ghostfolio/common/interfaces';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
@ -14,6 +17,7 @@ import { Account, DataSource, Type as ActivityType } from '@prisma/client';
import { isFinite, isNumber, isString } from 'lodash'; import { isFinite, isNumber, isString } from 'lodash';
import { parse as csvToJson } from 'papaparse'; import { parse as csvToJson } from 'papaparse';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -57,31 +61,38 @@ export class ImportActivitiesService {
const activities: CreateOrderDto[] = []; const activities: CreateOrderDto[] = [];
const assetProfiles: CreateAssetProfileWithMarketDataDto[] = []; const assetProfiles: CreateAssetProfileWithMarketDataDto[] = [];
const assetProfileSymbolMapping = new Map<string, string>();
for (const [index, item] of content.entries()) { for (const [index, item] of content.entries()) {
const currency = this.parseCurrency({ content, index, item }); const currency = this.parseCurrency({ content, index, item });
const dataSource = this.parseDataSource({ item });
const symbol = this.parseSymbol({ content, index, item });
const type = this.parseType({ content, index, item }); const type = this.parseType({ content, index, item });
activities.push({ let dataSource = this.parseDataSource({ item });
currency, let symbol = this.parseSymbol({ content, index, item });
dataSource,
symbol, if (!dataSource && ['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
type, // Apply the same data source as the import service
accountId: this.parseAccount({ item, userAccounts }), dataSource = DataSource.MANUAL;
comment: this.parseComment({ item }), }
date: this.parseDate({ content, index, item }),
fee: this.parseFee({ content, index, item }),
quantity: this.parseQuantity({ content, index, item }),
unitPrice: this.parseUnitPrice({ content, index, item }),
updateAccountBalance: false
});
if (dataSource === DataSource.MANUAL) { if (dataSource === DataSource.MANUAL) {
const name = symbol;
if (!isValidCustomAssetProfileSymbol(symbol)) {
// Generate a symbol and keep the free text as the name
symbol = assetProfileSymbolMapping.get(name) ?? uuidv4();
assetProfileSymbolMapping.set(name, symbol);
}
const isExistingAssetProfile = assetProfiles.some((assetProfile) => {
return assetProfile.symbol === symbol;
});
if (!isExistingAssetProfile) {
// Create synthetic asset profile for MANUAL data source // Create synthetic asset profile for MANUAL data source
assetProfiles.push({ assetProfiles.push({
currency, currency,
name,
symbol, symbol,
assetClass: undefined, assetClass: undefined,
assetSubClass: undefined, assetSubClass: undefined,
@ -96,13 +107,27 @@ export class ImportActivitiesService {
isActive: true, isActive: true,
isin: undefined, isin: undefined,
marketData: [], marketData: [],
name: symbol,
sectors: [], sectors: [],
url: undefined url: undefined
}); });
} }
} }
activities.push({
currency,
dataSource,
symbol,
type,
accountId: this.parseAccount({ item, userAccounts }),
comment: this.parseComment({ item }),
date: this.parseDate({ content, index, item }),
fee: this.parseFee({ content, index, item }),
quantity: this.parseQuantity({ content, index, item }),
unitPrice: this.parseUnitPrice({ content, index, item }),
updateAccountBalance: false
});
}
const result = await this.importJson({ const result = await this.importJson({
activities, activities,
assetProfiles, assetProfiles,

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

@ -10,7 +10,8 @@ import {
isAccountExcluded, isAccountExcluded,
isCurrency, isCurrency,
isCurrencySymbol, isCurrencySymbol,
isSplitRatio isSplitRatio,
isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
describe('Helper', () => { describe('Helper', () => {
@ -326,4 +327,32 @@ describe('Helper', () => {
); );
}); });
}); });
describe('Is valid custom asset profile symbol', () => {
it('Empty symbol', () => {
expect(isValidCustomAssetProfileSymbol('')).toEqual(false);
});
it('Free-text symbol', () => {
expect(isValidCustomAssetProfileSymbol('Penthouse Apartment')).toEqual(
false
);
});
it('Stock symbol', () => {
expect(isValidCustomAssetProfileSymbol('AAPL')).toEqual(false);
});
it('Symbol with Ghostfolio prefix', () => {
expect(isValidCustomAssetProfileSymbol('GF_PENTHOUSE_APARTMENT')).toEqual(
true
);
});
it('UUID', () => {
expect(
isValidCustomAssetProfileSymbol('7e91b7d4-1430-4212-8380-289a06c9bbc1')
).toEqual(true);
});
});
}); });

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

@ -8,7 +8,7 @@ import {
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isISO4217CurrencyCode } from 'class-validator'; import { isISO4217CurrencyCode, isUUID } from 'class-validator';
import { import {
getDate, getDate,
getMonth, getMonth,
@ -41,6 +41,7 @@ import {
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks, ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioPrefix,
SEARCH_QUERY_MINIMUM_LENGTH, SEARCH_QUERY_MINIMUM_LENGTH,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from './config'; } from './config';
@ -466,6 +467,14 @@ export function getYesterday() {
return subDays(new Date(Date.UTC(year, month, day)), 1); return subDays(new Date(Date.UTC(year, month, day)), 1);
} }
export function hasGhostfolioPrefix(aSymbol: string) {
if (!aSymbol) {
return false;
}
return aSymbol.startsWith(`${ghostfolioPrefix}_`);
}
export function interpolate(template: string, context: any) { export function interpolate(template: string, context: any) {
return template?.replace(/[$]{([^}]+)}/g, (_, objectPath) => { return template?.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
const properties = objectPath.split('.'); const properties = objectPath.split('.');
@ -548,6 +557,10 @@ export function isSplitRatio({
); );
} }
export function isValidCustomAssetProfileSymbol(aSymbol: string) {
return hasGhostfolioPrefix(aSymbol) || isUUID(aSymbol);
}
export function isValidSearchQuery(aQuery: string) { export function isValidSearchQuery(aQuery: string) {
return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH; return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH;
} }

2
libs/common/src/lib/interfaces/portfolio-position.interface.ts

@ -15,6 +15,7 @@ export interface PortfolioPosition {
| 'currency' | 'currency'
| 'dataSource' | 'dataSource'
| 'holdings' | 'holdings'
| 'isin'
| 'name' | 'name'
| 'sectors' | 'sectors'
| 'symbol' | 'symbol'
@ -25,7 +26,6 @@ export interface PortfolioPosition {
}; };
dateOfFirstActivity: Date; dateOfFirstActivity: Date;
dividend: number; dividend: number;
exchange?: string;
grossPerformance: number; grossPerformance: number;
grossPerformancePercent: number; grossPerformancePercent: number;
grossPerformancePercentWithCurrencyEffect: number; grossPerformancePercentWithCurrencyEffect: number;

5
libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.util.ts

@ -1,3 +1,4 @@
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
@ -105,8 +106,8 @@ export function getTagFilters(
): Filter[] { ): Filter[] {
return ( return (
tags tags
?.filter(({ isUsed }) => { ?.filter(({ id, isUsed }) => {
return isUsed; return id !== TAG_ID_EXCLUDE_FROM_ANALYSIS && isUsed;
}) })
?.map(({ id, name }) => { ?.map(({ id, name }) => {
return { return {

38
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.41.0", "version": "3.42.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.41.0", "version": "3.42.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -97,6 +97,7 @@
"tablemark": "4.1.0", "tablemark": "4.1.0",
"twitter-api-v2": "1.29.0", "twitter-api-v2": "1.29.0",
"undici": "8.5.0", "undici": "8.5.0",
"uuid": "14.0.1",
"yahoo-finance2": "4.0.0", "yahoo-finance2": "4.0.0",
"zod": "4.4.3", "zod": "4.4.3",
"zone.js": "0.16.1" "zone.js": "0.16.1"
@ -15985,6 +15986,16 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/bull/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/bundle-name": { "node_modules/bundle-name": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@ -32154,6 +32165,17 @@
"websocket-driver": "^0.7.4" "websocket-driver": "^0.7.4"
} }
}, },
"node_modules/sockjs/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"dev": true,
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/socks": { "node_modules/socks": {
"version": "2.8.7", "version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
@ -34664,12 +34686,16 @@
} }
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "8.3.2", "version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist-node/bin/uuid"
} }
}, },
"node_modules/v8-compile-cache-lib": { "node_modules/v8-compile-cache-lib": {

3
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.41.0", "version": "3.42.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",
@ -141,6 +141,7 @@
"tablemark": "4.1.0", "tablemark": "4.1.0",
"twitter-api-v2": "1.29.0", "twitter-api-v2": "1.29.0",
"undici": "8.5.0", "undici": "8.5.0",
"uuid": "14.0.1",
"yahoo-finance2": "4.0.0", "yahoo-finance2": "4.0.0",
"zod": "4.4.3", "zod": "4.4.3",
"zone.js": "0.16.1" "zone.js": "0.16.1"

27
test/import/not-ok/invalid-symbol-with-manual-data-source.json

@ -0,0 +1,27 @@
{
"meta": {
"date": "2023-02-05T00:00:00.000Z",
"version": "dev"
},
"activities": [
{
"accountId": null,
"comment": null,
"currency": "USD",
"dataSource": "MANUAL",
"date": "2022-01-01T00:00:00.000Z",
"fee": 0,
"quantity": 1,
"symbol": "Penthouse Apartment",
"tags": [],
"type": "BUY",
"unitPrice": 500000
}
],
"user": {
"settings": {
"currency": "USD",
"performanceCalculationType": "ROAI"
}
}
}

24
test/import/ok/without-accounts.json

@ -3,6 +3,28 @@
"date": "2022-04-01T00:00:00.000Z", "date": "2022-04-01T00:00:00.000Z",
"version": "dev" "version": "dev"
}, },
"assetProfiles": [
{
"assetClass": null,
"assetSubClass": null,
"comment": null,
"countries": [],
"currency": "USD",
"cusip": null,
"dataSource": "MANUAL",
"figi": null,
"figiComposite": null,
"figiShareClass": null,
"holdings": [],
"isActive": true,
"isin": null,
"marketData": [],
"name": "Penthouse Apartment",
"sectors": [],
"symbol": "7e91b7d4-1430-4212-8380-289a06c9bbc1",
"url": null
}
],
"activities": [ "activities": [
{ {
"fee": 0, "fee": 0,
@ -22,7 +44,7 @@
"currency": "USD", "currency": "USD",
"dataSource": "MANUAL", "dataSource": "MANUAL",
"date": "2022-01-01T00:00:00.000Z", "date": "2022-01-01T00:00:00.000Z",
"symbol": "Penthouse Apartment" "symbol": "7e91b7d4-1430-4212-8380-289a06c9bbc1"
}, },
{ {
"fee": 0, "fee": 0,

Loading…
Cancel
Save