Compare commits

..

12 Commits

Author SHA1 Message Date
c9368c5cf2 Release 2.45.0 (#2931) 2024-01-27 10:54:35 +01:00
29423efea3 Update translations (#2930) 2024-01-27 10:53:19 +01:00
f3ee99fb2b Feature/extend assistant by account selector (#2929)
* Add account selector to assistant

* Update changelog
2024-01-27 10:48:46 +01:00
3df8810412 Feature/Add support to grant private access with permissions (#2870)
* Add support to grant private access with permissions

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-01-27 09:44:13 +01:00
b8ca88c6df Add auto-renewal (#2920) 2024-01-27 08:46:27 +01:00
2c068c412d Feature/migrate tag selector to form group in assistant (#2926)
* Introduce filter form group

* Update changelog
2024-01-27 08:45:55 +01:00
9fdbd22cb5 Bugfix/fix activities import for manual data source (#2923)
* Fix import

* Update changelog
2024-01-27 08:41:45 +01:00
8f5f4c5875 Feature/format name in eod historical data service (#2922)
* Format name

* Update changelog
2024-01-26 22:37:47 +01:00
50fb82a6e6 Extend personal finance tools (#2925) 2024-01-26 22:37:26 +01:00
2c10cd7edf Bugfix/remove holdings with incomplete data from top3 bottom3 performers (#2921)
* Remove holdings with incomplete data

* Update changelog
2024-01-26 08:35:23 +01:00
bbde86c66e Feature/improve language localization for de 20240124 (#2918)
* Update translations

* Update changelog
2024-01-25 21:11:07 +01:00
73c0843d51 Feature/add permissions to access model (#2833)
* Add permissions to Access model

* Update changelog
2024-01-24 19:23:58 +01:00
52 changed files with 3711 additions and 1063 deletions

View File

@ -5,6 +5,25 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.45.0 - 2024-01-27
### Added
- Extended the assistant by an account selector (experimental)
- Added support to grant private access with permissions (experimental)
- Added `permissions` to the `Access` model
### Changed
- Migrated the tag selector to a form group in the assistant (experimental)
- Formatted the name in the _EOD Historical Data_ service
- Improved the language localization for German (`de`)
### Fixed
- Fixed the import for activities with `MANUAL` data source and type `FEE`, `INTEREST`, `ITEM` or `LIABILITY`
- Removed holdings with incomplete data from the _Top 3_ and _Bottom 3_ performers on the analysis page
## 2.44.0 - 2024-01-24
### Fixed

View File

@ -42,23 +42,27 @@ export class AccessController {
where: { userId: this.request.user.id }
});
return accessesWithGranteeUser.map((access) => {
if (access.GranteeUser) {
return accessesWithGranteeUser.map(
({ alias, GranteeUser, id, permissions }) => {
if (GranteeUser) {
return {
alias,
id,
permissions,
grantee: GranteeUser?.id,
type: 'PRIVATE'
};
}
return {
alias: access.alias,
grantee: access.GranteeUser?.id,
id: access.id,
type: 'RESTRICTED_VIEW'
alias,
id,
permissions,
grantee: 'Public',
type: 'PUBLIC'
};
}
return {
alias: access.alias,
grantee: 'Public',
id: access.id,
type: 'PUBLIC'
};
});
);
}
@HasPermission(permissions.createAccess)
@ -83,6 +87,7 @@ export class AccessController {
GranteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: undefined,
permissions: data.permissions,
User: { connect: { id: this.request.user.id } }
});
} catch {

View File

@ -1,4 +1,5 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
import { AccessPermission } from '@prisma/client';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateAccessDto {
@IsOptional()
@ -9,7 +10,7 @@ export class CreateAccessDto {
@IsUUID()
granteeUserId?: string;
@IsEnum(AccessPermission, { each: true })
@IsOptional()
@IsString()
type?: 'PUBLIC';
permissions?: AccessPermission[];
}

View File

@ -575,7 +575,7 @@ export class ImportService {
for (const [
index,
{ currency, dataSource, symbol }
{ currency, dataSource, symbol, type }
] of uniqueActivitiesDto.entries()) {
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
throw new Error(
@ -583,28 +583,33 @@ export class ImportService {
);
}
const assetProfile = (
await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
])
)?.[symbol];
const assetProfile = {
currency,
...(
await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
])
)?.[symbol]
};
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
if (
assetProfile.currency !== currency &&
!this.exchangeRateDataService.hasCurrencyPair(
currency,
assetProfile.currency
)
) {
throw new Error(
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
);
if (
assetProfile.currency !== currency &&
!this.exchangeRateDataService.hasCurrencyPair(
currency,
assetProfile.currency
)
) {
throw new Error(
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
);
}
}
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =

View File

@ -74,6 +74,11 @@ export class PortfolioController {
): Promise<PortfolioDetails & { hasError: boolean }> {
let hasDetails = true;
let hasError = false;
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = this.request.user.subscription.type === 'Premium';
@ -108,7 +113,7 @@ export class PortfolioController {
let portfolioSummary = summary;
if (
impersonationId ||
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
) {
const totalInvestment = Object.values(holdings)
@ -148,7 +153,7 @@ export class PortfolioController {
if (
hasDetails === false ||
impersonationId ||
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
) {
portfolioSummary = nullifyValuesInObject(summary, [
@ -164,6 +169,7 @@ export class PortfolioController {
'excludedAccountsAndActivities',
'fees',
'fireWealth',
'interest',
'items',
'liabilities',
'netWorth',
@ -216,6 +222,12 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioDividends> {
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -230,7 +242,7 @@ export class PortfolioController {
});
if (
impersonationId ||
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
) {
const maxDividend = dividends.reduce(
@ -266,6 +278,12 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> {
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -281,7 +299,7 @@ export class PortfolioController {
});
if (
impersonationId ||
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
) {
const maxInvestment = investments.reduce(
@ -329,6 +347,12 @@ export class PortfolioController {
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccounts = false
): Promise<PortfolioPerformanceResponse> {
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -344,7 +368,7 @@ export class PortfolioController {
});
if (
impersonationId ||
hasReadRestrictedAccessPermission ||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
this.userService.isRestrictedView(this.request.user)
) {

View File

@ -38,6 +38,10 @@ export class UpdateUserSettingDto {
@IsOptional()
emergencyFund?: number;
@IsArray()
@IsOptional()
'filters.accounts'?: string[];
@IsArray()
@IsOptional()
'filters.tags'?: string[];

View File

@ -105,6 +105,24 @@ export class UserService {
return usersWithAdminRole.length > 0;
}
public hasReadRestrictedAccessPermission({
impersonationId,
user
}: {
impersonationId: string;
user: UserWithSettings;
}) {
if (!impersonationId) {
return false;
}
const access = user.Access?.find(({ id }) => {
return id === impersonationId;
});
return access?.permissions?.includes('READ_RESTRICTED') ?? true;
}
public isRestrictedView(aUser: UserWithSettings) {
return aUser.Settings.settings.isRestrictedView ?? false;
}
@ -113,6 +131,7 @@ export class UserService {
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> {
const {
Access,
accessToken,
Account,
Analytics,
@ -127,6 +146,7 @@ export class UserService {
updatedAt
} = await this.prismaService.user.findUnique({
include: {
Access: true,
Account: {
include: { Platform: true }
},
@ -138,6 +158,7 @@ export class UserService {
});
const user: UserWithSettings = {
Access,
accessToken,
Account,
authChallenge,

View File

@ -230,6 +230,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -568,6 +572,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -926,6 +934,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -1130,6 +1142,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>

View File

@ -1,6 +1,7 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { UserWithSettings } from '@ghostfolio/common/types';
import {
CallHandler,
ExecutionContext,
@ -22,13 +23,20 @@ export class RedactValuesInResponseInterceptor<T>
): Observable<any> {
return next.handle().pipe(
map((data: any) => {
const request = context.switchToHttp().getRequest();
const hasImpersonationId =
!!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
const { headers, user }: { headers: Headers; user: UserWithSettings } =
context.switchToHttp().getRequest();
const impersonationId =
headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
const hasReadRestrictedPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user
});
if (
hasImpersonationId ||
this.userService.isRestrictedView(request.user)
hasReadRestrictedPermission ||
this.userService.isRestrictedView(user)
) {
data = redactAttributes({
object: data,

View File

@ -62,9 +62,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}, this.configurationService.get('REQUEST_TIMEOUT'));
return got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
'.'
)?.[0]}.json`,
`${TrackinsightDataEnhancerService.baseUrl}/funds/${
symbol.split('.')?.[0]
}.json`,
{
// @ts-ignore
signal: abortController.signal
@ -104,9 +104,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}, this.configurationService.get('REQUEST_TIMEOUT'));
return got(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(
'.'
)?.[0]}.json`,
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
symbol.split('.')?.[0]
}.json`,
{
// @ts-ignore
signal: abortController.signal

View File

@ -1,7 +1,11 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
DEFAULT_CURRENCY,
REPLACE_NAME_PARTS,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { isCurrency } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import {
@ -137,18 +141,11 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
if (name) {
name = name.replace('&amp;', '&');
name = name.replace('Amundi Index Solutions - ', '');
name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', '');
name = name.replace('iShares V PLC - ', '');
name = name.replace('iShares VI Public Limited Company - ', '');
name = name.replace('iShares VII PLC - ', '');
name = name.replace('Multi Units Luxembourg - ', '');
name = name.replace('VanEck ETFs N.V. - ', '');
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
name = name.replace('Vanguard Funds Public Limited Company - ', '');
name = name.replace('Vanguard Index Funds - ', '');
name = name.replace('Xtrackers (IE) Plc - ', '');
for (const part of REPLACE_NAME_PARTS) {
name = name.replace(part, '');
}
name = name.trim();
}
if (quoteType === 'FUTURE') {

View File

@ -11,7 +11,10 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import {
DEFAULT_CURRENCY,
REPLACE_NAME_PARTS
} from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import {
@ -362,6 +365,18 @@ export class EodHistoricalDataService implements DataProviderInterface {
return aSymbol;
}
private formatName({ name }: { name: string }) {
if (name) {
for (const part of REPLACE_NAME_PARTS) {
name = name.replace(part, '');
}
name = name.trim();
}
return name;
}
private async getSearchResult(aQuery: string): Promise<
(LookupItem & {
assetClass: AssetClass;
@ -397,9 +412,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
assetClass,
assetSubClass,
isin,
name,
currency: this.convertCurrency(Currency),
dataSource: this.getName(),
name: this.formatName({ name }),
symbol: `${Code}.${Exchange}`
};
}

View File

@ -42,10 +42,21 @@ export class ManualService implements DataProviderInterface {
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
const assetProfile: Partial<SymbolProfile> = {
dataSource: this.getName(),
symbol: aSymbol
};
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource: this.getName(), symbol: aSymbol }
]);
if (symbolProfile) {
assetProfile.currency = symbolProfile.currency;
assetProfile.name = symbolProfile.name;
}
return assetProfile;
}
public async getDividends({}: GetDividendsParams) {

View File

@ -17,8 +17,13 @@
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<div class="align-items-center d-flex">
<ion-icon class="mr-1" name="lock-closed-outline" />
<ng-container i18n>Restricted View</ng-container>
@if (element.permissions.includes('READ')) {
<ion-icon class="mr-1" name="lock-open-outline" />
<ng-container i18n>View</ng-container>
} @else if (element.permissions.includes('READ_RESTRICTED')) {
<ion-icon class="mr-1" name="lock-closed-outline" />
<ng-container i18n>Restricted view</ng-container>
}
</div>
</td>
</ng-container>

View File

@ -141,7 +141,7 @@
[user]="user"
(closed)="closeAssistant()"
(dateRangeChanged)="onDateRangeChange($event)"
(selectedTagChanged)="onSelectedTagChanged($event)"
(filtersChanged)="onFiltersChanged($event)"
/>
</mat-menu>
</li>

View File

@ -11,6 +11,7 @@ import {
import { MatDialog } from '@angular/material/dialog';
import { MatMenuTrigger } from '@angular/material/menu';
import { Router } from '@angular/router';
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
@ -20,11 +21,10 @@ import {
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { Filter, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
import { Tag } from '@prisma/client';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
@ -162,6 +162,34 @@ export class HeaderComponent implements OnChanges {
});
}
public onFiltersChanged(filters: Filter[]) {
const userSetting: UpdateUserSettingDto = {};
for (const filter of filters) {
let filtersType: string;
if (filter.type === 'ACCOUNT') {
filtersType = 'accounts';
} else if (filter.type === 'TAG') {
filtersType = 'tags';
}
userSetting[`filters.${filtersType}`] = filter.id ? [filter.id] : null;
}
this.dataService
.putUserSetting(userSetting)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
});
}
public onMenuClosed() {
this.isMenuOpen = false;
}
@ -174,20 +202,6 @@ export class HeaderComponent implements OnChanges {
this.assistantElement.initialize();
}
public onSelectedTagChanged(tag: Tag) {
this.dataService
.putUserSetting({ 'filters.tags': tag ? [tag.id] : null })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
});
}
public onSignOut() {
this.signOut.next();
}

View File

@ -74,7 +74,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
});
this.showDetails =
!this.hasImpersonationId &&
!this.user.settings.isRestrictedView &&
this.user.settings.viewMode !== 'ZEN';

View File

@ -1,14 +1,12 @@
<div class="container p-0">
<div class="no-gutters row">
<div
class="status-container text-muted text-right"
(click)="onShowErrors()"
>
<div class="status-container text-muted text-right">
@if (errors?.length > 0 && !isLoading) {
<ion-icon
i18n-title
name="time-outline"
title="Oops! Our data provider partner is experiencing the hiccups."
title="Oops! A data provider is experiencing the hiccups."
(click)="onShowErrors()"
/>
}
</div>

View File

@ -58,7 +58,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
duration: 1,
separator: getNumberFormatGroup(this.locale)
}).start();
} else if (this.performance?.currentValue === null) {
} else if (this.showDetails === false) {
new CountUp(
'value',
this.performance?.currentNetPerformancePercent * 100,
@ -69,6 +69,8 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
separator: getNumberFormatGroup(this.locale)
}
).start();
} else {
this.value.nativeElement.innerHTML = '*****';
}
}
}

View File

@ -10,8 +10,8 @@
[hidden]="summary?.ordersCount === null"
>
<div class="flex-grow-1 ml-3 text-truncate" i18n>
{{ summary?.ordersCount }} {summary?.ordersCount, plural, =1 {transaction}
other {transactions}}
{{ summary?.ordersCount }}
{summary?.ordersCount, plural, =1 {transaction} other {transactions}}
</div>
</div>
<div class="row">
@ -71,7 +71,11 @@
<div class="flex-nowrap px-3 py-1 row">
<div class="align-items-center d-flex flex-grow-1 ml-3 text-truncate">
<ng-container i18n>Gross Performance</ng-container>
<abbr class="initialism ml-2 text-muted" title="Time-Weighted Rate of Return">(TWR)</abbr>
<abbr
class="initialism ml-2 text-muted"
title="Time-Weighted Rate of Return"
>(TWR)</abbr
>
</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
@ -117,7 +121,11 @@
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate ml-3">
<ng-container i18n>Net Performance</ng-container>
<abbr class="initialism ml-2 text-muted" title="Time-Weighted Rate of Return">(TWR)</abbr>
<abbr
class="initialism ml-2 text-muted"
title="Time-Weighted Rate of Return"
>(TWR)</abbr
>
</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value

View File

@ -37,19 +37,23 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
ngOnInit() {
this.accessForm = this.formBuilder.group({
alias: [this.data.access.alias],
permissions: [this.data.access.permissions[0], Validators.required],
type: [this.data.access.type, Validators.required],
userId: [this.data.access.grantee, Validators.required]
});
this.accessForm.get('type').valueChanges.subscribe((value) => {
this.accessForm.get('type').valueChanges.subscribe((accessType) => {
const permissionsControl = this.accessForm.get('permissions');
const userIdControl = this.accessForm.get('userId');
if (value === 'PRIVATE') {
if (accessType === 'PRIVATE') {
permissionsControl.setValidators(Validators.required);
userIdControl.setValidators(Validators.required);
} else {
userIdControl.clearValidators();
}
permissionsControl.updateValueAndValidity();
userIdControl.updateValueAndValidity();
this.changeDetectorRef.markForCheck();
@ -64,7 +68,7 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
const access: CreateAccessDto = {
alias: this.accessForm.controls['alias'].value,
granteeUserId: this.accessForm.controls['userId'].value,
type: this.accessForm.controls['type'].value
permissions: [this.accessForm.controls['permissions'].value]
};
this.dataService

View File

@ -30,9 +30,20 @@
@if (accessForm.controls['type'].value === 'PRIVATE') {
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label
>Ghostfolio <ng-container i18n>User ID</ng-container></mat-label
>
<mat-label i18n>Permission</mat-label>
<mat-select formControlName="permissions">
<mat-option i18n value="READ_RESTRICTED">Restricted view</mat-option>
@if(data?.user?.settings?.isExperimentalFeatures) {
<mat-option i18n value="READ">View</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label>
Ghostfolio <ng-container i18n>User ID</ng-container>
</mat-label>
<input
formControlName="userId"
matInput

View File

@ -1,5 +1,6 @@
import { Access } from '@ghostfolio/common/interfaces';
import { Access, User } from '@ghostfolio/common/interfaces';
export interface CreateOrUpdateAccessDialogParams {
access: Access;
user: User;
}

View File

@ -7,7 +7,6 @@ import {
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Access, User } from '@ghostfolio/common/interfaces';
@ -105,8 +104,10 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
data: {
access: {
alias: '',
permissions: ['READ_RESTRICTED'],
type: 'PRIVATE'
}
},
user: this.user
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'

View File

@ -176,6 +176,19 @@
your university e-mail address.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Does the Ghostfolio Premium subscription renew
automatically?</mat-card-title
>
</mat-card-header>
<mat-card-content
>No, <a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> does
not include auto-renewal. Upon expiration, you can choose whether to
start a new subscription.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Which devices are supported?</mat-card-title>

View File

@ -15,7 +15,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { downloadAsFile } from '@ghostfolio/common/helper';
import { Filter, User } from '@ghostfolio/common/interfaces';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataSource, Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns';

View File

@ -379,7 +379,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ positions }) => {
const positionsSorted = sortBy(
positions,
positions.filter(({ netPerformancePercentage }) => {
return isNumber(netPerformancePercentage);
}),
'netPerformancePercentage'
).reverse();

View File

@ -44,6 +44,7 @@ import { SumioPageComponent } from './products/sumio-page.component';
import { TillerPageComponent } from './products/tiller-page.component';
import { UtlunaPageComponent } from './products/utluna-page.component';
import { VyzerPageComponent } from './products/vyzer-page.component';
import { WealthfolioPageComponent } from './products/wealthfolio-page.component';
import { WealthicaPageComponent } from './products/wealthica-page.component';
import { WhalPageComponent } from './products/whal-page.component';
import { YeekateePageComponent } from './products/yeekatee-page.component';
@ -528,6 +529,14 @@ export const products: Product[] = [
pricingPerYear: '$348',
slogan: 'Virtual Family Office for Smart Wealth Management'
},
{
component: WealthfolioPageComponent,
hasSelfHostingAbility: true,
key: 'wealthfolio',
languages: ['English'],
name: 'Wealthfolio',
slogan: 'Desktop Investment Tracker'
},
{
component: WealthicaPageComponent,
founded: 2015,

View File

@ -0,0 +1,32 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
import { BaseProductPageComponent } from './base-page.component';
@Component({
host: { class: 'page' },
imports: [CommonModule, MatButtonModule, RouterModule],
selector: 'gf-wealthfolio-page',
standalone: true,
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class WealthfolioPageComponent extends BaseProductPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});
public product2 = products.find(({ key }) => {
return key === 'wealthfolio';
});
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkResourcesPersonalFinanceTools = [
'/' + $localize`resources`,
'personal-finance-tools'
];
}

View File

@ -47,18 +47,26 @@ export class UserService extends ObservableStore<UserStoreState> {
}
public getFilters() {
const filters: Filter[] = [];
const user = this.getState().user;
return user?.settings?.isExperimentalFeatures === true
? user.settings['filters.tags']
? <Filter[]>[
{
id: user.settings['filters.tags'][0],
type: 'TAG'
}
]
: []
: [];
if (user?.settings?.isExperimentalFeatures === true) {
if (user.settings['filters.accounts']) {
filters.push({
id: user.settings['filters.accounts'][0],
type: 'ACCOUNT'
});
}
if (user.settings['filters.tags']) {
filters.push({
id: user.settings['filters.tags'][0],
type: 'TAG'
});
}
}
return filters;
}
public remove() {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -112,6 +112,21 @@ export const QUEUE_JOB_STATUS_LIST = <JobStatus[]>[
'waiting'
];
export const REPLACE_NAME_PARTS = [
'Amundi Index Solutions -',
'iShares ETF (CH) -',
'iShares III Public Limited Company -',
'iShares V PLC -',
'iShares VI Public Limited Company -',
'iShares VII PLC -',
'Multi Units Luxembourg -',
'VanEck ETFs N.V. -',
'Vaneck Vectors Ucits Etfs Plc -',
'Vanguard Funds Public Limited Company -',
'Vanguard Index Funds -',
'Xtrackers (IE) Plc -'
];
export const SUPPORTED_LANGUAGE_CODES = [
'de',
'en',

View File

@ -1,6 +1,10 @@
import { AccessType } from '@ghostfolio/common/types';
import { AccessPermission } from '@prisma/client';
export interface Access {
alias?: string;
grantee?: string;
id: string;
type: 'PRIVATE' | 'PUBLIC' | 'RESTRICTED_VIEW';
permissions: AccessPermission[];
type: AccessType;
}

View File

@ -7,6 +7,7 @@ export interface UserSettings {
colorScheme?: ColorScheme;
dateRange?: DateRange;
emergencyFund?: number;
'filters.accounts'?: string[];
'filters.tags'?: string[];
isExperimentalFeatures?: boolean;
isRestrictedView?: boolean;

View File

@ -0,0 +1 @@
export type AccessType = 'PRIVATE' | 'PUBLIC';

View File

@ -1,3 +1,4 @@
import type { AccessType } from './access-type.type';
import type { AccessWithGranteeUser } from './access-with-grantee-user.type';
import type { AccountWithPlatform } from './account-with-platform.type';
import type { AccountWithValue } from './account-with-value.type';
@ -18,6 +19,7 @@ import type { UserWithSettings } from './user-with-settings.type';
import type { ViewMode } from './view-mode.type';
export type {
AccessType,
AccessWithGranteeUser,
AccountWithPlatform,
AccountWithValue,

View File

@ -1,10 +1,11 @@
import { UserSettings } from '@ghostfolio/common/interfaces';
import { SubscriptionOffer } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Account, Settings, User } from '@prisma/client';
import { Access, Account, Settings, User } from '@prisma/client';
// TODO: Compare with User interface
export type UserWithSettings = User & {
Access: Access[];
Account: Account[];
activityCount: number;
permissions?: string[];

View File

@ -15,14 +15,14 @@ import {
ViewChild,
ViewChildren
} from '@angular/core';
import { FormControl } from '@angular/forms';
import { FormBuilder, FormControl } from '@angular/forms';
import { MatMenuTrigger } from '@angular/material/menu';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { User } from '@ghostfolio/common/interfaces';
import { Filter, User } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { Tag } from '@prisma/client';
import { Account, Tag } from '@prisma/client';
import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
import {
catchError,
@ -81,7 +81,7 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
@Output() closed = new EventEmitter<void>();
@Output() dateRangeChanged = new EventEmitter<DateRange>();
@Output() selectedTagChanged = new EventEmitter<Tag>();
@Output() filtersChanged = new EventEmitter<Filter[]>();
@ViewChild('menuTrigger') menuTriggerElement: MatMenuTrigger;
@ViewChild('search', { static: true }) searchElement: ElementRef;
@ -91,6 +91,7 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5;
public accounts: Account[] = [];
public dateRangeFormControl = new FormControl<string>(undefined);
public readonly dateRangeOptions = [
{ label: $localize`Today`, value: '1d' },
@ -110,6 +111,10 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
{ label: $localize`5Y`, value: '5y' },
{ label: $localize`Max`, value: 'max' }
];
public filterForm = this.formBuilder.group({
account: new FormControl<string>(undefined),
tag: new FormControl<string>(undefined)
});
public isLoading = false;
public isOpen = false;
public placeholder = $localize`Find holding...`;
@ -119,7 +124,6 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
holdings: []
};
public tags: Tag[] = [];
public tagsFormControl = new FormControl<string>(undefined);
private keyManager: FocusKeyManager<AssistantListItemComponent>;
private unsubscribeSubject = new Subject<void>();
@ -127,12 +131,14 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService
private dataService: DataService,
private formBuilder: FormBuilder
) {}
public ngOnInit() {
const { tags } = this.dataService.fetchInfo();
this.accounts = this.user?.accounts;
this.tags = tags.map(({ id, name }) => {
return {
id,
@ -140,6 +146,23 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
};
});
this.filterForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ account, tag }) => {
this.filtersChanged.emit([
{
id: account,
type: 'ACCOUNT'
},
{
id: tag,
type: 'TAG'
}
]);
this.onCloseAssistant();
});
this.searchFormControl.valueChanges
.pipe(
map((searchTerm) => {
@ -181,8 +204,15 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
public ngOnChanges() {
this.dateRangeFormControl.setValue(this.user?.settings?.dateRange ?? null);
this.tagsFormControl.setValue(
this.user?.settings?.['filters.tags']?.[0] ?? null
this.filterForm.setValue(
{
account: this.user?.settings?.['filters.accounts']?.[0] ?? null,
tag: this.user?.settings?.['filters.tags']?.[0] ?? null
},
{
emitEvent: false
}
);
}
@ -219,16 +249,6 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit {
this.closed.emit();
}
public onTagChange() {
const selectedTag = this.tags.find(({ id }) => {
return id === this.tagsFormControl.value;
});
this.selectedTagChanged.emit(selectedTag);
this.onCloseAssistant();
}
public setIsOpen(aIsOpen: boolean) {
this.isOpen = aIsOpen;
}

View File

@ -86,57 +86,80 @@
</div>
</div>
</div>
<div
*ngIf="!(isLoading || searchFormControl.value) && user?.settings?.isExperimentalFeatures"
class="filter-container"
>
<mat-tab-group
animationDuration="0"
mat-align-tabs="start"
[mat-stretch-tabs]="false"
(click)="$event.stopPropagation();"
<form [formGroup]="filterForm">
<div
*ngIf="!(isLoading || searchFormControl.value) && user?.settings?.isExperimentalFeatures"
class="filter-container"
>
<mat-tab>
<ng-template mat-tab-label
><ion-icon class="mr-2" name="calendar-clear-outline" /><span i18n
>Date Range</span
></ng-template
>
<div class="p-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select
[formControl]="dateRangeFormControl"
(selectionChange)="onChangeDateRange($event.value)"
>
@for (range of dateRangeOptions; track range) {
<mat-option [value]="range.value">{{ range.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label
><ion-icon class="mr-2" name="pricetag-outline" /><span i18n
>Tags</span
></ng-template
>
<div class="p-3">
<mat-radio-group
color="primary"
[formControl]="tagsFormControl"
(change)="onTagChange()"
<mat-tab-group
animationDuration="0"
mat-align-tabs="start"
[mat-stretch-tabs]="false"
(click)="$event.stopPropagation();"
>
<mat-tab>
<ng-template mat-tab-label
><ion-icon name="calendar-clear-outline" /><span
class="d-none d-sm-block ml-2"
i18n
>Date Range</span
></ng-template
>
<mat-radio-button class="d-flex flex-column" i18n [value]="null"
>No tag</mat-radio-button
>
@for (tag of tags; track tag.id) {
<mat-radio-button class="d-flex flex-column" [value]="tag.id"
>{{ tag.name }}</mat-radio-button
>
}
</mat-radio-group>
</div>
</mat-tab>
</mat-tab-group>
</div>
<div class="p-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select
[formControl]="dateRangeFormControl"
(selectionChange)="onChangeDateRange($event.value)"
>
@for (range of dateRangeOptions; track range) {
<mat-option [value]="range.value">{{ range.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label
><ion-icon name="albums-outline" /><span
class="d-none d-sm-block ml-2"
i18n
>Accounts</span
></ng-template
>
<div class="p-3">
<mat-radio-group color="primary" formControlName="account">
<mat-radio-button class="d-flex flex-column" i18n [value]="null"
>No account</mat-radio-button
>
@for (account of accounts; track account.id) {
<mat-radio-button class="d-flex flex-column" [value]="account.id"
>{{ account.name }}</mat-radio-button
>
}
</mat-radio-group>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label
><ion-icon name="pricetag-outline" /><span
class="d-none d-sm-block ml-2"
i18n
>Tags</span
></ng-template
>
<div class="p-3">
<mat-radio-group color="primary" formControlName="tag">
<mat-radio-button class="d-flex flex-column" i18n [value]="null"
>No tag</mat-radio-button
>
@for (tag of tags; track tag.id) {
<mat-radio-button class="d-flex flex-column" [value]="tag.id"
>{{ tag.name }}</mat-radio-button
>
}
</mat-radio-group>
</div>
</mat-tab>
</mat-tab-group>
</div>
</form>

View File

@ -2,6 +2,10 @@
display: block;
.filter-container {
.mat-mdc-tab-group {
max-height: 40vh;
}
::ng-deep {
label {
margin-bottom: 0;

View File

@ -32,7 +32,7 @@
}"
>
<ng-container *ngIf="value === null">
<span class="text-monospace text-muted">***</span>
<span class="text-monospace text-muted">*****</span>
</ng-container>
<ng-container *ngIf="value !== null">
{{ formattedValue }}

View File

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

View File

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "AccessPermission" AS ENUM ('READ', 'READ_RESTRICTED');
-- AlterTable
ALTER TABLE "Access" ADD COLUMN "permissions" "AccessPermission"[] DEFAULT ARRAY['READ_RESTRICTED']::"AccessPermission"[];

View File

@ -11,31 +11,32 @@ datasource db {
model Access {
alias String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
granteeUserId String?
id String @id @default(uuid())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
permissions AccessPermission[] @default([READ_RESTRICTED])
updatedAt DateTime @updatedAt
userId String
GranteeUser User? @relation("accessGet", fields: [granteeUserId], references: [id])
User User @relation("accessGive", fields: [userId], references: [id])
GranteeUser User? @relation("accessGet", fields: [granteeUserId], references: [id])
User User @relation("accessGive", fields: [userId], references: [id])
}
model Account {
balance Float @default(0)
balances AccountBalance[]
comment String?
createdAt DateTime @default(now())
currency String?
id String @default(uuid())
isDefault Boolean @default(false)
isExcluded Boolean @default(false)
name String?
platformId String?
updatedAt DateTime @updatedAt
userId String
Platform Platform? @relation(fields: [platformId], references: [id])
User User @relation(fields: [userId], references: [id])
Order Order[]
balance Float @default(0)
balances AccountBalance[]
comment String?
createdAt DateTime @default(now())
currency String?
id String @default(uuid())
isDefault Boolean @default(false)
isExcluded Boolean @default(false)
name String?
platformId String?
updatedAt DateTime @updatedAt
userId String
Platform Platform? @relation(fields: [platformId], references: [id])
User User @relation(fields: [userId], references: [id])
Order Order[]
@@id([id, userId])
}
@ -196,6 +197,11 @@ model User {
Subscription Subscription[]
}
enum AccessPermission {
READ
READ_RESTRICTED
}
enum AssetClass {
CASH
COMMODITY