Browse Source

Merge branch 'main' into enhancement/migrate-zen-page-component

pull/5410/head
David Requeno 2 months ago
parent
commit
e482544787
  1. 4
      CHANGELOG.md
  2. 5
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  3. 4
      apps/api/src/services/data-provider/data-provider.service.ts
  4. 14
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  5. 3
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  6. 176
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  7. 11
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  8. 17
      apps/api/src/services/data-provider/manual/manual.service.ts
  9. 11
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  10. 10
      apps/client/src/app/components/access-table/access-table.component.html
  11. 8
      apps/client/src/app/components/access-table/access-table.component.ts
  12. 16
      apps/client/src/app/components/rule/rule.component.html
  13. 4
      apps/client/src/app/components/rule/rule.component.ts
  14. 36
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
  15. 11
      libs/common/src/lib/personal-finance-tools.ts
  16. 4
      package-lock.json
  17. 2
      package.json

4
CHANGELOG.md

@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## 2.194.0 - 2025-08-27
### Added ### Added
@ -13,7 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Moved the support to customize rules in the _X-ray_ section from experimental to general availability
- Improved the create or update activity dialog’s asset sub class selector for valuables to update the options dynamically based on the selected asset class - Improved the create or update activity dialog’s asset sub class selector for valuables to update the options dynamically based on the selected asset class
- Improved the error handling in data providers
- Randomized the minutes of the hourly data gathering cron job - Randomized the minutes of the hourly data gathering cron job
- Refactored the dialog footer component to standalone - Refactored the dialog footer component to standalone
- Refactored the dialog header component to standalone - Refactored the dialog header component to standalone

5
apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts

@ -163,7 +163,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
public async getAssetProfile( public async getAssetProfile(
aSymbol: string aSymbol: string
): Promise<Partial<SymbolProfile>> { ): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {}; let response: Partial<SymbolProfile> = {};
try { try {
let symbol = aSymbol; let symbol = aSymbol;
@ -241,10 +241,13 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
} }
const url = assetProfile.summaryProfile?.website; const url = assetProfile.summaryProfile?.website;
if (url) { if (url) {
response.url = url; response.url = url;
} }
} catch (error) { } catch (error) {
response = undefined;
if (error.message === `Quote not found for symbol: ${aSymbol}`) { if (error.message === `Quote not found for symbol: ${aSymbol}`) {
throw new AssetProfileDelistedError( throw new AssetProfileDelistedError(
`No data found, ${aSymbol} (${this.getName()}) may be delisted` `No data found, ${aSymbol} (${this.getName()}) may be delisted`

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

@ -107,7 +107,9 @@ export class DataProviderService implements OnModuleInit {
promises.push( promises.push(
promise.then((symbolProfile) => { promise.then((symbolProfile) => {
response[symbol] = symbolProfile; if (symbolProfile) {
response[symbol] = symbolProfile;
}
}) })
); );
} }

14
apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts

@ -55,14 +55,18 @@ export class EodHistoricalDataService implements DataProviderInterface {
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
const [searchResult] = await this.getSearchResult(symbol); const [searchResult] = await this.getSearchResult(symbol);
if (!searchResult) {
return undefined;
}
return { return {
symbol, symbol,
assetClass: searchResult?.assetClass, assetClass: searchResult.assetClass,
assetSubClass: searchResult?.assetSubClass, assetSubClass: searchResult.assetSubClass,
currency: this.convertCurrency(searchResult?.currency), currency: this.convertCurrency(searchResult.currency),
dataSource: this.getName(), dataSource: this.getName(),
isin: searchResult?.isin, isin: searchResult.isin,
name: searchResult?.name name: searchResult.name
}; };
} }

3
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -64,7 +64,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol symbol
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = { let response: Partial<SymbolProfile> = {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()
}; };
@ -201,6 +201,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} }
} catch (error) { } catch (error) {
let message = error; let message = error;
response = undefined;
if (['AbortError', 'TimeoutError'].includes(error?.name)) { if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${(

176
apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts

@ -51,20 +51,26 @@ export class GhostfolioService implements DataProviderInterface {
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol symbol
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
let response: DataProviderGhostfolioAssetProfileResponse = {}; let assetProfile: DataProviderGhostfolioAssetProfileResponse;
try { try {
const assetProfile = (await fetch( const response = await fetch(
`${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`, `${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => );
res.json()
)) as DataProviderGhostfolioAssetProfileResponse; if (!response.ok) {
throw new Response(await response.text(), {
status: response.status,
statusText: response.statusText
});
}
response = assetProfile; assetProfile =
(await response.json()) as DataProviderGhostfolioAssetProfileResponse;
} catch (error) { } catch (error) {
let message = error; let message = error;
@ -72,24 +78,21 @@ export class GhostfolioService implements DataProviderInterface {
message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${(
requestTimeout / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} else if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if ( } else if (
error?.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes(
error?.status
)
) { ) {
message = 'RequestError: The daily request limit has been exceeded'; message =
} else if (error?.response?.statusCode === StatusCodes.UNAUTHORIZED) { 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
if (!error?.request?.options?.headers?.authorization?.includes('-')) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} else {
message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
}
} }
Logger.error(message, 'GhostfolioService'); Logger.error(message, 'GhostfolioService');
} }
return response; return assetProfile;
} }
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
@ -110,12 +113,12 @@ export class GhostfolioService implements DataProviderInterface {
}: GetDividendsParams): Promise<{ }: GetDividendsParams): Promise<{
[date: string]: IDataProviderHistoricalResponse; [date: string]: IDataProviderHistoricalResponse;
}> { }> {
let response: { let dividends: {
[date: string]: IDataProviderHistoricalResponse; [date: string]: IDataProviderHistoricalResponse;
} = {}; } = {};
try { try {
const { dividends } = (await fetch( const response = await fetch(
`${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( `${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
to, to,
DATE_FORMAT DATE_FORMAT
@ -124,28 +127,34 @@ export class GhostfolioService implements DataProviderInterface {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json())) as DividendsResponse; );
if (!response.ok) {
throw new Response(await response.text(), {
status: response.status,
statusText: response.statusText
});
}
response = dividends; dividends = ((await response.json()) as DividendsResponse).dividends;
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded'; message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { } else if (
if (!error.request?.options?.headers?.authorization?.includes('-')) { [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes(
message = error?.status
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; )
} else { ) {
message = message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
} }
Logger.error(message, 'GhostfolioService'); Logger.error(message, 'GhostfolioService');
} }
return response; return dividends;
} }
public async getHistorical({ public async getHistorical({
@ -158,7 +167,7 @@ export class GhostfolioService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
try { try {
const { historicalData } = (await fetch( const response = await fetch(
`${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( `${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
to, to,
DATE_FORMAT DATE_FORMAT
@ -167,27 +176,36 @@ export class GhostfolioService implements DataProviderInterface {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json())) as HistoricalResponse; );
if (!response.ok) {
throw new Response(await response.text(), {
status: response.status,
statusText: response.statusText
});
}
const { historicalData } = (await response.json()) as HistoricalResponse;
return { return {
[symbol]: historicalData [symbol]: historicalData
}; };
} catch (error) { } catch (error) {
let message = error; if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
error.name = 'RequestError';
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { error.message =
message = 'RequestError: The daily request limit has been exceeded'; 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { } else if (
if (!error.request?.options?.headers?.authorization?.includes('-')) { [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes(
message = error?.status
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; )
} else { ) {
message = error.name = 'RequestError';
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; error.message =
} 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} }
Logger.error(message, 'GhostfolioService'); Logger.error(error.message, 'GhostfolioService');
throw new Error( throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format( `Could not get historical market data for ${symbol} (${this.getName()}) from ${format(
@ -212,22 +230,29 @@ export class GhostfolioService implements DataProviderInterface {
}: GetQuotesParams): Promise<{ }: GetQuotesParams): Promise<{
[symbol: string]: IDataProviderResponse; [symbol: string]: IDataProviderResponse;
}> { }> {
let response: { [symbol: string]: IDataProviderResponse } = {}; let quotes: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) { if (symbols.length <= 0) {
return response; return quotes;
} }
try { try {
const { quotes } = (await fetch( const response = await fetch(
`${this.URL}/v2/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`, `${this.URL}/v2/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json())) as QuotesResponse; );
if (!response.ok) {
throw new Response(await response.text(), {
status: response.status,
statusText: response.statusText
});
}
response = quotes; quotes = ((await response.json()) as QuotesResponse).quotes;
} catch (error) { } catch (error) {
let message = error; let message = error;
@ -237,24 +262,21 @@ export class GhostfolioService implements DataProviderInterface {
)} was aborted because the request to the data provider took more than ${( )} was aborted because the request to the data provider took more than ${(
requestTimeout / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} else if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if ( } else if (
error?.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes(
error?.status
)
) { ) {
message = 'RequestError: The daily request limit has been exceeded'; message =
} else if (error?.response?.statusCode === StatusCodes.UNAUTHORIZED) { 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
if (!error?.request?.options?.headers?.authorization?.includes('-')) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} else {
message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
}
} }
Logger.error(message, 'GhostfolioService'); Logger.error(message, 'GhostfolioService');
} }
return response; return quotes;
} }
public getTestSymbol() { public getTestSymbol() {
@ -268,13 +290,22 @@ export class GhostfolioService implements DataProviderInterface {
let searchResult: LookupResponse = { items: [] }; let searchResult: LookupResponse = { items: [] };
try { try {
searchResult = (await fetch( const response = await fetch(
`${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`, `${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json())) as LookupResponse; );
if (!response.ok) {
throw new Response(await response.text(), {
status: response.status,
statusText: response.statusText
});
}
searchResult = (await response.json()) as LookupResponse;
} catch (error) { } catch (error) {
let message = error; let message = error;
@ -282,18 +313,15 @@ export class GhostfolioService implements DataProviderInterface {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
requestTimeout / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} else if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if ( } else if (
error?.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes(
error?.status
)
) { ) {
message = 'RequestError: The daily request limit has been exceeded'; message =
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
if (!error?.request?.options?.headers?.authorization?.includes('-')) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} else {
message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
}
} }
Logger.error(message, 'GhostfolioService'); Logger.error(message, 'GhostfolioService');

11
apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts

@ -36,13 +36,10 @@ export class GoogleSheetsService implements DataProviderInterface {
return true; return true;
} }
public async getAssetProfile({ public async getAssetProfile({}: GetAssetProfileParams): Promise<
symbol Partial<SymbolProfile>
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> { > {
return { return undefined;
symbol,
dataSource: this.getName()
};
} }
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {

17
apps/api/src/services/data-provider/manual/manual.service.ts

@ -45,21 +45,20 @@ export class ManualService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
const assetProfile: Partial<SymbolProfile> = {
symbol,
dataSource: this.getName()
};
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([ const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ symbol, dataSource: this.getName() } { symbol, dataSource: this.getName() }
]); ]);
if (symbolProfile) { if (!symbolProfile) {
assetProfile.currency = symbolProfile.currency; return undefined;
assetProfile.name = symbolProfile.name;
} }
return assetProfile; return {
symbol,
currency: symbolProfile.currency,
dataSource: this.getName(),
name: symbolProfile.name
};
} }
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {

11
apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts

@ -35,13 +35,10 @@ export class RapidApiService implements DataProviderInterface {
return !!this.configurationService.get('API_KEY_RAPID_API'); return !!this.configurationService.get('API_KEY_RAPID_API');
} }
public async getAssetProfile({ public async getAssetProfile({}: GetAssetProfileParams): Promise<
symbol Partial<SymbolProfile>
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> { > {
return { return undefined;
symbol,
dataSource: this.getName()
};
} }
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {

10
apps/client/src/app/components/access-table/access-table.component.html

@ -67,12 +67,18 @@
<mat-menu #transactionMenu="matMenu" xPosition="before"> <mat-menu #transactionMenu="matMenu" xPosition="before">
@if (element.type === 'PUBLIC') { @if (element.type === 'PUBLIC') {
<button mat-menu-item (click)="onCopyUrlToClipboard(element.id)"> <button mat-menu-item (click)="onCopyUrlToClipboard(element.id)">
<ng-container i18n>Copy link to clipboard</ng-container> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="copy-outline" />
<span i18n>Copy link to clipboard</span>
</span>
</button> </button>
<hr class="my-0" /> <hr class="my-0" />
} }
<button mat-menu-item (click)="onDeleteAccess(element.id)"> <button mat-menu-item (click)="onDeleteAccess(element.id)">
<ng-container i18n>Revoke</ng-container> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="remove-circle-outline" />
<span i18n>Revoke</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

8
apps/client/src/app/components/access-table/access-table.component.ts

@ -22,10 +22,12 @@ import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
copyOutline,
ellipsisHorizontal, ellipsisHorizontal,
linkOutline, linkOutline,
lockClosedOutline, lockClosedOutline,
lockOpenOutline lockOpenOutline,
removeCircleOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms from 'ms'; import ms from 'ms';
@ -62,10 +64,12 @@ export class GfAccessTableComponent implements OnChanges {
private snackBar: MatSnackBar private snackBar: MatSnackBar
) { ) {
addIcons({ addIcons({
copyOutline,
ellipsisHorizontal, ellipsisHorizontal,
linkOutline, linkOutline,
lockClosedOutline, lockClosedOutline,
lockOpenOutline lockOpenOutline,
removeCircleOutline
}); });
} }

16
apps/client/src/app/components/rule/rule.component.html

@ -64,14 +64,24 @@
<mat-menu #rulesMenu="matMenu" xPosition="before"> <mat-menu #rulesMenu="matMenu" xPosition="before">
@if (rule?.isActive && rule?.configuration) { @if (rule?.isActive && rule?.configuration) {
<button mat-menu-item (click)="onCustomizeRule(rule)"> <button mat-menu-item (click)="onCustomizeRule(rule)">
<ng-container i18n>Customize</ng-container>... <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="options-outline" />
<span><ng-container i18n>Customize</ng-container>...</span>
</span>
</button> </button>
<hr class="my-0" />
} }
<button mat-menu-item (click)="onUpdateRule(rule)"> <button mat-menu-item (click)="onUpdateRule(rule)">
@if (rule?.isActive) { @if (rule?.isActive) {
<ng-container i18n>Deactivate</ng-container> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="remove-circle-outline" />
<span i18n>Deactivate</span>
</span>
} @else { } @else {
<ng-container i18n>Activate</ng-container> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="add-circle-outline" />
<span i18n>Activate</span>
</span>
} }
</button> </button>
</mat-menu> </mat-menu>

4
apps/client/src/app/components/rule/rule.component.ts

@ -16,8 +16,10 @@ import {
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
addCircleOutline,
checkmarkCircleOutline, checkmarkCircleOutline,
ellipsisHorizontal, ellipsisHorizontal,
optionsOutline,
removeCircleOutline, removeCircleOutline,
warningOutline warningOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
@ -50,8 +52,10 @@ export class RuleComponent implements OnInit {
private dialog: MatDialog private dialog: MatDialog
) { ) {
addIcons({ addIcons({
addCircleOutline,
checkmarkCircleOutline, checkmarkCircleOutline,
ellipsisHorizontal, ellipsisHorizontal,
optionsOutline,
removeCircleOutline, removeCircleOutline,
warningOutline warningOutline
}); });

36
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html

@ -73,9 +73,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="liquidityRules" [rules]="liquidityRules"
@ -97,9 +95,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="emergencyFundRules" [rules]="emergencyFundRules"
@ -121,9 +117,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="currencyClusterRiskRules" [rules]="currencyClusterRiskRules"
@ -145,9 +139,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="assetClassClusterRiskRules" [rules]="assetClassClusterRiskRules"
@ -169,9 +161,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="accountClusterRiskRules" [rules]="accountClusterRiskRules"
@ -193,9 +183,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="economicMarketClusterRiskRules" [rules]="economicMarketClusterRiskRules"
@ -217,9 +205,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="regionalMarketClusterRiskRules" [rules]="regionalMarketClusterRiskRules"
@ -241,9 +227,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="feeRules" [rules]="feeRules"
@ -256,9 +240,7 @@
<h4 class="m-0" i18n>Inactive</h4> <h4 class="m-0" i18n>Inactive</h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="inactiveRules" [rules]="inactiveRules"

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

@ -427,6 +427,15 @@ export const personalFinanceTools: Product[] = [
slogan: 'Die All-in-One Lösung für dein Vermögen.', slogan: 'Die All-in-One Lösung für dein Vermögen.',
useAnonymously: true useAnonymously: true
}, },
{
founded: 2022,
key: 'income-reign',
languages: ['English'],
name: 'Income Reign',
note: 'Income Reign was discontinued in 2025',
origin: 'United States',
pricingPerYear: '$120'
},
{ {
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
@ -452,6 +461,7 @@ export const personalFinanceTools: Product[] = [
hasSelfHostingAbility: true, hasSelfHostingAbility: true,
key: 'invmon', key: 'invmon',
name: 'InvMon', name: 'InvMon',
note: 'Originally named as A2PB',
origin: 'Switzerland', origin: 'Switzerland',
pricingPerYear: '$156', pricingPerYear: '$156',
slogan: 'Track all your assets, investments and portfolios in one place', slogan: 'Track all your assets, investments and portfolios in one place',
@ -985,6 +995,7 @@ export const personalFinanceTools: Product[] = [
founded: 2024, founded: 2024,
hasSelfHostingAbility: true, hasSelfHostingAbility: true,
isArchived: true, isArchived: true,
isOpenSource: true,
key: 'wealthfolio', key: 'wealthfolio',
languages: ['English'], languages: ['English'],
name: 'Wealthfolio', name: 'Wealthfolio',

4
package-lock.json

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

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.193.0", "version": "2.194.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",

Loading…
Cancel
Save