Merge branch 'main' of gitea.suda.codes:giteauser/ghostfolio-mirror

This commit is contained in:
ksyasuda 2024-09-28 12:25:12 -07:00
commit a05203c785
35 changed files with 435 additions and 260 deletions

View File

@ -9,10 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Optimized the portfolio calculations with smarter cloning of activities
- Integrated the add currency functionality into the market data section of the admin control panel
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.19.1` to `5.20.0`
- Upgraded `webpack-bundle-analyzer` from version `4.10.1` to `4.10.2`
## 2.110.0 - 2024-09-24
### Changed
- Improved the usability of various action menus by introducing horizontal lines to separate the delete action
- Improved the chart in the account detail dialog (experimental)
- Aligned the holdings and regions of the public page with the allocations page
- Considered the users language in the link of the access table to share the portfolio - Considered the users language in the link of the access table to share the portfolio
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
## 2.109.0 - 2024-09-17 ## 2.109.0 - 2024-09-21
### Added ### Added

View File

@ -2,14 +2,18 @@ import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper';
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces'; import {
import { UserWithSettings } from '@ghostfolio/common/types'; AccountBalancesResponse,
Filter,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client'; import { AccountBalance, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
import { CreateAccountBalanceDto } from './create-account-balance.dto'; import { CreateAccountBalanceDto } from './create-account-balance.dto';
@ -91,17 +95,55 @@ export class AccountBalanceService {
return accountBalance; return accountBalance;
} }
public async getAccountBalanceItems({
filters,
userCurrency,
userId
}: {
filters?: Filter[];
userCurrency: string;
userId: string;
}): Promise<HistoricalDataItem[]> {
const { balances } = await this.getAccountBalances({
filters,
userCurrency,
userId,
withExcludedAccounts: false // TODO
});
const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } =
{};
const lastBalancesByAccount: { [accountId: string]: Big } = {};
for (const { accountId, date, valueInBaseCurrency } of balances) {
const formattedDate = format(date, DATE_FORMAT);
lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency);
const totalBalance = getSum(Object.values(lastBalancesByAccount));
// Add or update the accumulated balance for this date
accumulatedBalancesByDate[formattedDate] = {
date: formattedDate,
value: totalBalance.toNumber()
};
}
return Object.values(accumulatedBalancesByDate);
}
@LogPerformance @LogPerformance
public async getAccountBalances({ public async getAccountBalances({
filters, filters,
user, userCurrency,
userId,
withExcludedAccounts withExcludedAccounts
}: { }: {
filters?: Filter[]; filters?: Filter[];
user: UserWithSettings; userCurrency: string;
userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<AccountBalancesResponse> { }): Promise<AccountBalancesResponse> {
const where: Prisma.AccountBalanceWhereInput = { userId: user.id }; const where: Prisma.AccountBalanceWhereInput = { userId };
const accountFilter = filters?.find(({ type }) => { const accountFilter = filters?.find(({ type }) => {
return type === 'ACCOUNT'; return type === 'ACCOUNT';
@ -132,10 +174,11 @@ export class AccountBalanceService {
balances: balances.map((balance) => { balances: balances.map((balance) => {
return { return {
...balance, ...balance,
accountId: balance.Account.id,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value, balance.value,
balance.Account.currency, balance.Account.currency,
user.Settings.settings.baseCurrency userCurrency
) )
}; };
}) })

View File

@ -137,7 +137,8 @@ export class AccountController {
): Promise<AccountBalancesResponse> { ): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({ return this.accountBalanceService.getAccountBalances({
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
user: this.request.user userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id
}); });
} }

View File

@ -63,7 +63,6 @@ export class PublicController {
{ performance: performanceYtd } { performance: performanceYtd }
] = await Promise.all([ ] = await Promise.all([
this.portfolioService.getDetails({ this.portfolioService.getDetails({
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
impersonationId: access.userId, impersonationId: access.userId,
userId: user.id, userId: user.id,
withMarkets: true withMarkets: true
@ -114,6 +113,7 @@ export class PublicController {
publicPortfolioResponse.holdings[symbol] = { publicPortfolioResponse.holdings[symbol] = {
allocationInPercentage: allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue, portfolioPosition.valueInBaseCurrency / totalValue,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined, currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource, dataSource: portfolioPosition.dataSource,

View File

@ -104,6 +104,10 @@ export abstract class PortfolioCalculator {
let dateOfFirstActivity = new Date(); let dateOfFirstActivity = new Date();
if (this.accountBalanceItems[0]) {
dateOfFirstActivity = parseDate(this.accountBalanceItems[0].date);
}
this.activities = activities this.activities = activities
.map( .map(
({ ({
@ -269,6 +273,10 @@ export abstract class PortfolioCalculator {
) )
}); });
for (const accountBalanceItem of this.accountBalanceItems) {
chartDateMap[accountBalanceItem.date] = true;
}
const chartDates = sortBy(Object.keys(chartDateMap), (chartDate) => { const chartDates = sortBy(Object.keys(chartDateMap), (chartDate) => {
return chartDate; return chartDate;
}); });
@ -447,9 +455,28 @@ export abstract class PortfolioCalculator {
} }
} }
let lastDate = chartDates[0]; const accountBalanceItemsMap = this.accountBalanceItems.reduce(
(map, { date, value }) => {
map[date] = new Big(value);
return map;
},
{} as { [date: string]: Big }
);
const accountBalanceMap: { [date: string]: Big } = {};
let lastKnownBalance = new Big(0);
for (const dateString of chartDates) { for (const dateString of chartDates) {
if (accountBalanceItemsMap[dateString] !== undefined) {
// If there's an exact balance for this date, update lastKnownBalance
lastKnownBalance = accountBalanceItemsMap[dateString];
}
// Add the most recent balance to the accountBalanceMap
accountBalanceMap[dateString] = lastKnownBalance;
for (const symbol of Object.keys(valuesBySymbol)) { for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol]; const symbolValues = valuesBySymbol[symbol];
@ -492,18 +519,7 @@ export abstract class PortfolioCalculator {
accumulatedValuesByDate[dateString] accumulatedValuesByDate[dateString]
?.investmentValueWithCurrencyEffect ?? new Big(0) ?.investmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueWithCurrencyEffect), ).add(investmentValueWithCurrencyEffect),
totalAccountBalanceWithCurrencyEffect: this.accountBalanceItems.some( totalAccountBalanceWithCurrencyEffect: accountBalanceMap[dateString],
({ date }) => {
return date === dateString;
}
)
? new Big(
this.accountBalanceItems.find(({ date }) => {
return date === dateString;
}).value
)
: (accumulatedValuesByDate[lastDate]
?.totalAccountBalanceWithCurrencyEffect ?? new Big(0)),
totalCurrentValue: ( totalCurrentValue: (
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0) accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue), ).add(currentValue),
@ -537,8 +553,6 @@ export abstract class PortfolioCalculator {
).add(timeWeightedInvestmentValueWithCurrencyEffect) ).add(timeWeightedInvestmentValueWithCurrencyEffect)
}; };
} }
lastDate = dateString;
} }
const historicalData: HistoricalDataItem[] = Object.entries( const historicalData: HistoricalDataItem[] = Object.entries(
@ -733,12 +747,12 @@ export abstract class PortfolioCalculator {
timeWeightedInvestmentValue === 0 timeWeightedInvestmentValue === 0
? 0 ? 0
: netPerformanceWithCurrencyEffectSinceStartDate / : netPerformanceWithCurrencyEffectSinceStartDate /
timeWeightedInvestmentValue, timeWeightedInvestmentValue
// TODO: Add net worth with valuables // TODO: Add net worth with valuables
// netWorth: totalCurrentValueWithCurrencyEffect // netWorth: totalCurrentValueWithCurrencyEffect
// .plus(totalAccountBalanceWithCurrencyEffect) // .plus(totalAccountBalanceWithCurrencyEffect)
// .toNumber() // .toNumber()
netWorth: 0 // netWorth: 0
}); });
} }
} }
@ -815,7 +829,7 @@ export abstract class PortfolioCalculator {
endDate: Date; endDate: Date;
startDate: Date; startDate: Date;
step: number; step: number;
}) { }): { [date: string]: true } {
// Create a map of all relevant chart dates: // Create a map of all relevant chart dates:
// 1. Add transaction point dates // 1. Add transaction point dates
let chartDateMap = this.transactionPoints.reduce((result, { date }) => { let chartDateMap = this.transactionPoints.reduce((result, { date }) => {

View File

@ -180,10 +180,10 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let valueAtStartDateWithCurrencyEffect: Big; let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders // Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(this.activities).filter( let orders: PortfolioOrderItem[] = cloneDeep(
({ SymbolProfile }) => { this.activities.filter(({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol; return SymbolProfile.symbol === symbol;
} })
); );
if (orders.length <= 0) { if (orders.length <= 0) {

View File

@ -1058,35 +1058,12 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const accountBalances = await this.accountBalanceService.getAccountBalances( const accountBalanceItems =
{ filters, user, withExcludedAccounts } await this.accountBalanceService.getAccountBalanceItems({
); filters,
userId,
let accountBalanceItems: HistoricalDataItem[] = Object.values( userCurrency
// Reduce the array to a map with unique dates as keys });
accountBalances.balances.reduce(
(
map: { [date: string]: HistoricalDataItem },
{ date, valueInBaseCurrency }
) => {
const formattedDate = format(date, DATE_FORMAT);
if (map[formattedDate]) {
// If the value exists, add the current value to the existing one
map[formattedDate].value += valueInBaseCurrency;
} else {
// Otherwise, initialize the value for that date
map[formattedDate] = {
date: formattedDate,
value: valueInBaseCurrency
};
}
return map;
},
{}
)
);
const { activities } = const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({ await this.orderService.getOrdersForPortfolioCalculator({

View File

@ -1,3 +1,4 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
@ -17,6 +18,7 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
@Module({ @Module({
exports: [BullModule, PortfolioSnapshotService], exports: [BullModule, PortfolioSnapshotService],
imports: [ imports: [
AccountBalanceModule,
BullModule.registerQueue({ BullModule.registerQueue({
name: PORTFOLIO_SNAPSHOT_QUEUE name: PORTFOLIO_SNAPSHOT_QUEUE
}), }),

View File

@ -1,3 +1,4 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { import {
PerformanceCalculationType, PerformanceCalculationType,
@ -24,6 +25,7 @@ import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queu
@Processor(PORTFOLIO_SNAPSHOT_QUEUE) @Processor(PORTFOLIO_SNAPSHOT_QUEUE)
export class PortfolioSnapshotProcessor { export class PortfolioSnapshotProcessor {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
@ -56,7 +58,15 @@ export class PortfolioSnapshotProcessor {
userId: job.data.userId userId: job.data.userId
}); });
const accountBalanceItems =
await this.accountBalanceService.getAccountBalanceItems({
filters: job.data.filters,
userCurrency: job.data.userCurrency,
userId: job.data.userId
});
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
accountBalanceItems,
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: job.data.userCurrency, currency: job.data.userCurrency,

View File

@ -69,6 +69,7 @@
<button mat-menu-item (click)="onCopyToClipboard(element.id)"> <button mat-menu-item (click)="onCopyToClipboard(element.id)">
<ng-container i18n>Copy link to clipboard</ng-container> <ng-container i18n>Copy link to clipboard</ng-container>
</button> </button>
<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> <ng-container i18n>Revoke</ng-container>

View File

@ -2,7 +2,7 @@ import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/cre
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { import {
AccountBalancesResponse, AccountBalancesResponse,
HistoricalDataItem, HistoricalDataItem,
@ -27,7 +27,7 @@ import { Router } from '@angular/router';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { Subject } from 'rxjs'; import { forkJoin, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { AccountDetailDialogParams } from './interfaces/interfaces'; import { AccountDetailDialogParams } from './interfaces/interfaces';
@ -87,11 +87,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.fetchAccount(); this.initialize();
this.fetchAccountBalances();
this.fetchActivities();
this.fetchPortfolioHoldings();
this.fetchPortfolioPerformance();
} }
public onCloneActivity(aActivity: Activity) { public onCloneActivity(aActivity: Activity) {
@ -111,9 +107,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.postAccountBalance(accountBalance) .postAccountBalance(accountBalance)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.fetchAccount(); this.initialize();
this.fetchAccountBalances();
this.fetchPortfolioPerformance();
}); });
} }
@ -122,9 +116,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.deleteAccountBalance(aId) .deleteAccountBalance(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.fetchAccount(); this.initialize();
this.fetchAccountBalances();
this.fetchPortfolioPerformance();
}); });
} }
@ -198,17 +190,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
); );
} }
private fetchAccountBalances() {
this.dataService
.fetchAccountBalances(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ balances }) => {
this.accountBalances = balances;
this.changeDetectorRef.markForCheck();
});
}
private fetchActivities() { private fetchActivities() {
this.isLoadingActivities = true; this.isLoadingActivities = true;
@ -229,6 +210,58 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}); });
} }
private fetchChart() {
this.isLoadingChart = true;
forkJoin({
accountBalances: this.dataService
.fetchAccountBalances(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject)),
portfolioPerformance: this.dataService
.fetchPortfolioPerformance({
filters: [
{
id: this.data.accountId,
type: 'ACCOUNT'
}
],
range: 'max',
withExcludedAccounts: true,
withItems: true
})
.pipe(takeUntil(this.unsubscribeSubject))
}).subscribe({
error: () => {
this.isLoadingChart = false;
},
next: ({ accountBalances, portfolioPerformance }) => {
this.accountBalances = accountBalances.balances;
if (portfolioPerformance.chart.length > 0) {
this.historicalDataItems = portfolioPerformance.chart.map(
({ date, netWorth, netWorthInPercentage }) => ({
date,
value: isNumber(netWorth) ? netWorth : netWorthInPercentage
})
);
} else {
this.historicalDataItems = this.accountBalances.map(
({ date, valueInBaseCurrency }) => {
return {
date: format(date, DATE_FORMAT),
value: valueInBaseCurrency
};
}
);
}
this.isLoadingChart = false;
this.changeDetectorRef.markForCheck();
}
});
}
private fetchPortfolioHoldings() { private fetchPortfolioHoldings() {
this.dataService this.dataService
.fetchPortfolioHoldings({ .fetchPortfolioHoldings({
@ -247,36 +280,11 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}); });
} }
private fetchPortfolioPerformance() { private initialize() {
this.isLoadingChart = true; this.fetchAccount();
this.fetchActivities();
this.dataService this.fetchChart();
.fetchPortfolioPerformance({ this.fetchPortfolioHoldings();
filters: [
{
id: this.data.accountId,
type: 'ACCOUNT'
}
],
range: 'max',
withExcludedAccounts: true,
withItems: true
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => {
this.historicalDataItems = chart.map(
({ date, netWorth, netWorthInPercentage }) => {
return {
date,
value: isNumber(netWorth) ? netWorth : netWorthInPercentage
};
}
);
this.isLoadingChart = false;
this.changeDetectorRef.markForCheck();
});
} }
public ngOnDestroy() { public ngOnDestroy() {

View File

@ -20,7 +20,7 @@
</div> </div>
</div> </div>
<!-- TODO @if (user?.settings?.isExperimentalFeatures) {
<div class="chart-container mb-3"> <div class="chart-container mb-3">
<gf-investment-chart <gf-investment-chart
class="h-100" class="h-100"
@ -33,7 +33,7 @@
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />
</div> </div>
--> }
<div class="mb-3 row"> <div class="mb-3 row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">

View File

@ -278,6 +278,7 @@
<span i18n>Edit</span> <span i18n>Edit</span>
</span> </span>
</button> </button>
<hr class="m-0" />
<button <button
mat-menu-item mat-menu-item
[disabled]="element.transactionCount > 0" [disabled]="element.transactionCount > 0"

View File

@ -175,6 +175,7 @@
<button mat-menu-item (click)="onExecuteJob(element.id)"> <button mat-menu-item (click)="onExecuteJob(element.id)">
<ng-container i18n>Execute Job</ng-container> <ng-container i18n>Execute Job</ng-container>
</button> </button>
<hr class="m-0" />
<button mat-menu-item (click)="onDeleteJob(element.id)"> <button mat-menu-item (click)="onDeleteJob(element.id)">
<ng-container i18n>Delete Job</ng-container> <ng-container i18n>Delete Job</ng-container>
</button> </button>

View File

@ -197,6 +197,7 @@
<button mat-menu-item (click)="onGatherProfileData()"> <button mat-menu-item (click)="onGatherProfileData()">
<ng-container i18n>Gather Profile Data</ng-container> <ng-container i18n>Gather Profile Data</ng-container>
</button> </button>
<hr class="m-0" />
<button <button
mat-menu-item mat-menu-item
[disabled]="!selection.hasValue()" [disabled]="!selection.hasValue()"
@ -230,6 +231,7 @@
<span i18n>Edit</span> <span i18n>Edit</span>
</span> </span>
</a> </a>
<hr class="m-0" />
<button <button
mat-menu-item mat-menu-item
[disabled]=" [disabled]="

View File

@ -2,7 +2,10 @@ import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/con
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import {
getCurrencyFromSymbol,
isDerivedCurrency
} from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
AdminMarketDataItem AdminMarketDataItem
@ -74,7 +77,7 @@ export class AdminMarketDataService {
return ( return (
activitiesCount === 0 && activitiesCount === 0 &&
!isBenchmark && !isBenchmark &&
!isCurrency(getCurrencyFromSymbol(symbol)) && !isDerivedCurrency(getCurrencyFromSymbol(symbol)) &&
!symbol.startsWith(ghostfolioScraperApiSymbolPrefix) !symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
); );
} }

View File

@ -44,6 +44,7 @@
> >
<ng-container i18n>Gather Profile Data</ng-container> <ng-container i18n>Gather Profile Data</ng-container>
</button> </button>
<hr class="m-0" />
<button <button
mat-menu-item mat-menu-item
type="button" type="button"

View File

@ -1,7 +1,10 @@
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
OnDestroy, OnDestroy,
OnInit OnInit
@ -15,6 +18,10 @@ import {
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog'; import { MatDialogRef } from '@angular/material/dialog';
import { uniq } from 'lodash';
import { Subject, takeUntil } from 'rxjs';
import { CreateAssetProfileDialogMode } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -25,17 +32,29 @@ import { MatDialogRef } from '@angular/material/dialog';
}) })
export class CreateAssetProfileDialog implements OnInit, OnDestroy { export class CreateAssetProfileDialog implements OnInit, OnDestroy {
public createAssetProfileForm: FormGroup; public createAssetProfileForm: FormGroup;
public mode: 'auto' | 'manual'; public mode: CreateAssetProfileDialogMode;
private customCurrencies: string[];
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
public readonly adminService: AdminService, public readonly adminService: AdminService,
private readonly changeDetectorRef: ChangeDetectorRef,
private readonly dataService: DataService,
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>, public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
public readonly formBuilder: FormBuilder public readonly formBuilder: FormBuilder
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.initializeCustomCurrencies();
this.createAssetProfileForm = this.formBuilder.group( this.createAssetProfileForm = this.formBuilder.group(
{ {
addCurrency: new FormControl(null, [
Validators.maxLength(3),
Validators.minLength(3),
Validators.required
]),
addSymbol: new FormControl(null, [Validators.required]), addSymbol: new FormControl(null, [Validators.required]),
searchSymbol: new FormControl(null, [Validators.required]) searchSymbol: new FormControl(null, [Validators.required])
}, },
@ -51,34 +70,75 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
this.dialogRef.close(); this.dialogRef.close();
} }
public onRadioChange(mode: 'auto' | 'manual') { public onRadioChange(mode: CreateAssetProfileDialogMode) {
this.mode = mode; this.mode = mode;
} }
public onSubmit() { public onSubmit() {
this.mode === 'auto' if (this.mode === 'auto') {
? this.dialogRef.close({ this.dialogRef.close({
dataSource: dataSource:
this.createAssetProfileForm.get('searchSymbol').value.dataSource, this.createAssetProfileForm.get('searchSymbol').value.dataSource,
symbol: this.createAssetProfileForm.get('searchSymbol').value.symbol symbol: this.createAssetProfileForm.get('searchSymbol').value.symbol
});
} else if (this.mode === 'currency') {
const currency = this.createAssetProfileForm
.get('addCurrency')
.value.toUpperCase();
const currencies = uniq([...this.customCurrencies, currency]);
this.dataService
.putAdminSetting(PROPERTY_CURRENCIES, {
value: JSON.stringify(currencies)
}) })
: this.dialogRef.close({ .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dialogRef.close();
});
} else if (this.mode === 'manual') {
this.dialogRef.close({
dataSource: 'MANUAL', dataSource: 'MANUAL',
symbol: this.createAssetProfileForm.get('addSymbol').value symbol: this.createAssetProfileForm.get('addSymbol').value
}); });
} }
}
public ngOnDestroy() {} public get showCurrencyErrorMessage() {
const addCurrencyFormControl =
this.createAssetProfileForm.get('addCurrency');
if (
addCurrencyFormControl.hasError('maxlength') ||
addCurrencyFormControl.hasError('minlength')
) {
return true;
}
return false;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private atLeastOneValid(control: AbstractControl): ValidationErrors { private atLeastOneValid(control: AbstractControl): ValidationErrors {
const addCurrencyControl = control.get('addCurrency');
const addSymbolControl = control.get('addSymbol'); const addSymbolControl = control.get('addSymbol');
const searchSymbolControl = control.get('searchSymbol'); const searchSymbolControl = control.get('searchSymbol');
if (addSymbolControl.valid && searchSymbolControl.valid) { if (
addCurrencyControl.valid &&
addSymbolControl.valid &&
searchSymbolControl.valid
) {
return { atLeastOneValid: true }; return { atLeastOneValid: true };
} }
if ( if (
addCurrencyControl.valid ||
!addCurrencyControl ||
addSymbolControl.valid || addSymbolControl.valid ||
!addSymbolControl || !addSymbolControl ||
searchSymbolControl.valid || searchSymbolControl.valid ||
@ -89,4 +149,15 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
return { atLeastOneValid: true }; return { atLeastOneValid: true };
} }
private initializeCustomCurrencies() {
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ settings }) => {
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
this.changeDetectorRef.markForCheck();
});
}
} }

View File

@ -17,6 +17,9 @@
<mat-radio-button class="ml-3" name="manual" value="manual"> <mat-radio-button class="ml-3" name="manual" value="manual">
</mat-radio-button> </mat-radio-button>
<label class="m-0" for="manual" i18n>Add Manually</label> <label class="m-0" for="manual" i18n>Add Manually</label>
<mat-radio-button class="ml-3" name="currency" value="currency">
</mat-radio-button>
<label class="m-0" for="currency" i18n>Add Currency</label>
</mat-radio-group> </mat-radio-group>
</div> </div>
@ -37,6 +40,16 @@
<input formControlName="addSymbol" matInput /> <input formControlName="addSymbol" matInput />
</mat-form-field> </mat-form-field>
</div> </div>
} @else if (mode === 'currency') {
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label>
<input formControlName="addCurrency" matInput />
@if (showCurrencyErrorMessage) {
<mat-error i18n>Oops! Invalid currency.</mat-error>
}
</mat-form-field>
</div>
} }
</div> </div>
<div class="d-flex justify-content-end" mat-dialog-actions> <div class="d-flex justify-content-end" mat-dialog-actions>

View File

@ -2,3 +2,5 @@ export interface CreateAssetProfileDialogParams {
deviceType: string; deviceType: string;
locale: string; locale: string;
} }
export type CreateAssetProfileDialogMode = 'auto' | 'currency' | 'manual';

View File

@ -126,7 +126,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
if (currency) { if (currency) {
if (currency.length === 3) { if (currency.length === 3) {
const currencies = uniq([...this.customCurrencies, currency]); const currencies = uniq([
...this.customCurrencies,
currency.toUpperCase()
]);
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies }); this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
} else { } else {
this.notificationService.alert({ this.notificationService.alert({

View File

@ -79,6 +79,7 @@
</span> </span>
</a> </a>
@if (customCurrencies.includes(exchangeRate.label2)) { @if (customCurrencies.includes(exchangeRate.label2)) {
<hr class="m-0" />
<button <button
mat-menu-item mat-menu-item
(click)="onDeleteCurrency(exchangeRate.label2)" (click)="onDeleteCurrency(exchangeRate.label2)"

View File

@ -92,6 +92,7 @@
<span i18n>Edit</span> <span i18n>Edit</span>
</span> </span>
</button> </button>
<hr class="m-0" />
<button <button
mat-menu-item mat-menu-item
[disabled]="element.accountCount > 0" [disabled]="element.accountCount > 0"

View File

@ -71,6 +71,7 @@
<span i18n>Edit</span> <span i18n>Edit</span>
</span> </span>
</button> </button>
<hr class="m-0" />
<button <button
mat-menu-item mat-menu-item
[disabled]="element.activityCount > 0" [disabled]="element.activityCount > 0"

View File

@ -218,6 +218,7 @@
<span i18n>Impersonate User</span> <span i18n>Impersonate User</span>
</span> </span>
</button> </button>
<hr class="m-0" />
} }
<button <button
mat-menu-item mat-menu-item

View File

@ -4,7 +4,7 @@
<article> <article>
<div class="mb-4 text-center"> <div class="mb-4 text-center">
<h1 class="mb-1">Hacktoberfest 2024</h1> <h1 class="mb-1">Hacktoberfest 2024</h1>
<div class="mb-3 text-muted"><small>2024-09-21</small></div> <div class="mb-3 text-muted"><small>2024-09-24</small></div>
<img <img
alt="Hacktoberfest 2024 with Ghostfolio Teaser" alt="Hacktoberfest 2024 with Ghostfolio Teaser"
class="rounded w-100" class="rounded w-100"
@ -21,7 +21,7 @@
>third time</a >third time</a
> >
and look forward to connecting with new, enthusiastic open-source and look forward to connecting with new, enthusiastic open-source
contributors. Hacktoberfest is a a month-long celebration of all contributors. Hacktoberfest is a month-long celebration of all
things open-source: projects, their maintainers, and the entire things open-source: projects, their maintainers, and the entire
community of contributors. Every October, open source maintainers community of contributors. Every October, open source maintainers
around the globe dedicate extra time to support new contributors around the globe dedicate extra time to support new contributors

View File

@ -18,7 +18,7 @@
> >
<div class="flex-grow-1 overflow-hidden"> <div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">Hacktoberfest 2024</div> <div class="h6 m-0 text-truncate">Hacktoberfest 2024</div>
<div class="d-flex text-muted">2024-09-21</div> <div class="d-flex text-muted">2024-09-24</div>
</div> </div>
<div class="align-items-center d-flex"> <div class="align-items-center d-flex">
<ion-icon <ion-icon

View File

@ -52,8 +52,10 @@
</p> </p>
<ol> <ol>
<li>Go to the <i>Admin Control</i> panel</li> <li>Go to the <i>Admin Control</i> panel</li>
<li>Click on the <i>Add Currency</i> button</li> <li>Go to the <i>Market Data</i> section</li>
<li>Insert e.g. <code>EUR</code> in the prompt</li> <li>Click on the <i>+</i> button</li>
<li>Switch to <i>Add Currency</i></li>
<li>Insert e.g. <code>EUR</code> for Euro</li>
</ol> </ol>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

View File

@ -9,6 +9,7 @@ import { Market } from '@ghostfolio/common/types';
import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { AssetClass } from '@prisma/client';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -145,6 +146,9 @@ export class PublicPageComponent implements OnInit {
value: position.allocationInPercentage value: position.allocationInPercentage
}; };
if (position.assetClass !== AssetClass.LIQUIDITY) {
// Prepare analysis data by continents, countries, holdings and sectors except for liquidity
if (position.countries.length > 0) { if (position.countries.length > 0) {
this.markets.developedMarkets.value += this.markets.developedMarkets.value +=
position.markets.developedMarkets * position.valueInBaseCurrency; position.markets.developedMarkets * position.valueInBaseCurrency;
@ -164,18 +168,21 @@ export class PublicPageComponent implements OnInit {
name: continent, name: continent,
value: value:
weight * weight *
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency
}; };
} }
if (this.countries[code]?.value) { if (this.countries[code]?.value) {
this.countries[code].value += weight * position.valueInBaseCurrency; this.countries[code].value +=
weight * position.valueInBaseCurrency;
} else { } else {
this.countries[code] = { this.countries[code] = {
name, name,
value: value:
weight * weight *
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency
}; };
} }
} }
@ -201,7 +208,8 @@ export class PublicPageComponent implements OnInit {
name, name,
value: value:
weight * weight *
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency
}; };
} }
} }
@ -209,6 +217,7 @@ export class PublicPageComponent implements OnInit {
this.sectors[UNKNOWN_KEY].value += this.sectors[UNKNOWN_KEY].value +=
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency; this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency;
} }
}
this.symbols[prettifySymbol(symbol)] = { this.symbols[prettifySymbol(symbol)] = {
name: position.name, name: position.name,

View File

@ -2391,7 +2391,7 @@
</trans-unit> </trans-unit>
<trans-unit id="a3d148b40a389fda0665eb583c9e434ec5ee1ced" datatype="html"> <trans-unit id="a3d148b40a389fda0665eb583c9e434ec5ee1ced" datatype="html">
<source> Ghostfolio empowers you to keep track of your wealth. </source> <source> Ghostfolio empowers you to keep track of your wealth. </source>
<target state="translated">Ghostfolio verschafft Ihnen den Überblick über Ihr Vermögen.</target> <target state="translated">Ghostfolio verschafft dir den Überblick über dein Vermögen.</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/public/public-page.html</context>
<context context-type="linenumber">215</context> <context context-type="linenumber">215</context>
@ -4919,7 +4919,7 @@
</trans-unit> </trans-unit>
<trans-unit id="c8ef12032b654cfd51b9ae1082fde84247945e03" datatype="html"> <trans-unit id="c8ef12032b654cfd51b9ae1082fde84247945e03" datatype="html">
<source> Protect your <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>assets<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/>. Refine your <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>personal investment strategy<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/>. </source> <source> Protect your <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>assets<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/>. Refine your <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>personal investment strategy<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/>. </source>
<target state="translated"> Schützen Sie Ihr <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Vermögen<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/>. Optimieren Sie Ihre <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>persönliche Anlagestrategie<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/>. </target> <target state="translated"> Schütze dein <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>Vermögen<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/>. Optimiere deine <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/>persönliche Anlagestrategie<x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/>. </target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/landing/landing-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/landing/landing-page.html</context>
<context context-type="linenumber">225</context> <context context-type="linenumber">225</context>
@ -5728,7 +5728,7 @@
</trans-unit> </trans-unit>
<trans-unit id="2fc47ae80c47144eb6250979fe927a010da3aee5" datatype="html"> <trans-unit id="2fc47ae80c47144eb6250979fe927a010da3aee5" datatype="html">
<source>Choose or drop a file here</source> <source>Choose or drop a file here</source>
<target state="translated">Wählen Sie eine Datei aus oder ziehen Sie sie hierhin</target> <target state="translated">Wähle eine Datei aus oder ziehe sie hierhin</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html</context>
<context context-type="linenumber">84</context> <context context-type="linenumber">84</context>
@ -5984,7 +5984,7 @@
</trans-unit> </trans-unit>
<trans-unit id="metaDescription" datatype="html"> <trans-unit id="metaDescription" datatype="html">
<source> Ghostfolio is a personal finance dashboard to keep track of your net worth including cash, stocks, ETFs and cryptocurrencies across multiple platforms. </source> <source> Ghostfolio is a personal finance dashboard to keep track of your net worth including cash, stocks, ETFs and cryptocurrencies across multiple platforms. </source>
<target state="translated"> Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Cash, Aktien, ETFs und Kryptowährungen über mehrere Finanzinstitute überwachen. </target> <target state="translated"> Mit dem Finanz-Dashboard Ghostfolio kannst du dein Vermögen in Cash, Aktien, ETFs und Kryptowährungen über mehrere Finanzinstitute überwachen. </target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">4</context> <context context-type="linenumber">4</context>

View File

@ -1,7 +1,7 @@
import { AccountBalance } from '@prisma/client'; import { AccountBalance } from '@prisma/client';
export interface AccountBalancesResponse { export interface AccountBalancesResponse {
balances: (Pick<AccountBalance, 'date' | 'id' | 'value'> & { balances: (Pick<AccountBalance, 'accountId' | 'date' | 'id' | 'value'> & {
valueInBaseCurrency: number; valueInBaseCurrency: number;
})[]; })[];
} }

View File

@ -7,6 +7,7 @@ export interface PublicPortfolioResponse extends PublicPortfolioResponseV1 {
[symbol: string]: Pick< [symbol: string]: Pick<
PortfolioPosition, PortfolioPosition,
| 'allocationInPercentage' | 'allocationInPercentage'
| 'assetClass'
| 'countries' | 'countries'
| 'currency' | 'currency'
| 'dataSource' | 'dataSource'

View File

@ -55,6 +55,7 @@
</span> </span>
</button> </button>
} }
<hr class="m-0" />
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
mat-menu-item mat-menu-item
@ -445,6 +446,7 @@
<span i18n>Export Draft as ICS</span> <span i18n>Export Draft as ICS</span>
</span> </span>
</button> </button>
<hr class="m-0" />
<button <button
mat-menu-item mat-menu-item
[disabled]="!hasPermissionToDeleteActivity" [disabled]="!hasPermissionToDeleteActivity"

87
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.108.0", "version": "2.110.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.108.0", "version": "2.110.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -40,7 +40,7 @@
"@nestjs/platform-express": "10.1.3", "@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2", "@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0", "@nestjs/serve-static": "4.0.0",
"@prisma/client": "5.19.1", "@prisma/client": "5.20.0",
"@simplewebauthn/browser": "9.0.1", "@simplewebauthn/browser": "9.0.1",
"@simplewebauthn/server": "9.0.3", "@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "3.5.0", "@stripe/stripe-js": "3.5.0",
@ -84,7 +84,7 @@
"passport": "0.7.0", "passport": "0.7.0",
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
"prisma": "5.19.1", "prisma": "5.20.0",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rxjs": "7.5.6", "rxjs": "7.5.6",
"stripe": "15.11.0", "stripe": "15.11.0",
@ -158,7 +158,7 @@
"ts-node": "10.9.2", "ts-node": "10.9.2",
"tslib": "2.6.0", "tslib": "2.6.0",
"typescript": "5.5.3", "typescript": "5.5.3",
"webpack-bundle-analyzer": "4.10.1" "webpack-bundle-analyzer": "4.10.2"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@ -9646,9 +9646,9 @@
"dev": true "dev": true
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "5.19.1", "version": "5.20.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.19.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.20.0.tgz",
"integrity": "sha512-x30GFguInsgt+4z5I4WbkZP2CGpotJMUXy+Gl/aaUjHn2o1DnLYNTA+q9XdYmAQZM8fIIkvUiA2NpgosM3fneg==", "integrity": "sha512-CLv55ZuMuUawMsxoqxGtLT3bEZoa2W8L3Qnp6rDIFWy+ZBrUcOFKdoeGPSnbBqxc3SkdxJrF+D1veN/WNynZYA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@ -9664,48 +9664,48 @@
} }
}, },
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "5.19.1", "version": "5.20.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.19.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.20.0.tgz",
"integrity": "sha512-lAG6A6QnG2AskAukIEucYJZxxcSqKsMK74ZFVfCTOM/7UiyJQi48v6TQ47d6qKG3LbMslqOvnTX25dj/qvclGg==", "integrity": "sha512-oCx79MJ4HSujokA8S1g0xgZUGybD4SyIOydoHMngFYiwEwYDQ5tBQkK5XoEHuwOYDKUOKRn/J0MEymckc4IgsQ==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "5.19.1", "version": "5.20.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.19.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.20.0.tgz",
"integrity": "sha512-kR/PoxZDrfUmbbXqqb8SlBBgCjvGaJYMCOe189PEYzq9rKqitQ2fvT/VJ8PDSe8tTNxhc2KzsCfCAL+Iwm/7Cg==", "integrity": "sha512-DtqkP+hcZvPEbj8t8dK5df2b7d3B8GNauKqaddRRqQBBlgkbdhJkxhoJTrOowlS3vaRt2iMCkU0+CSNn0KhqAQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "5.19.1", "@prisma/debug": "5.20.0",
"@prisma/engines-version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3", "@prisma/engines-version": "5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284",
"@prisma/fetch-engine": "5.19.1", "@prisma/fetch-engine": "5.20.0",
"@prisma/get-platform": "5.19.1" "@prisma/get-platform": "5.20.0"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3", "version": "5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284.tgz",
"integrity": "sha512-xR6rt+z5LnNqTP5BBc+8+ySgf4WNMimOKXRn6xfNRDSpHvbOEmd7+qAOmzCrddEc4Cp8nFC0txU14dstjH7FXA==", "integrity": "sha512-Lg8AS5lpi0auZe2Mn4gjuCg081UZf88k3cn0RCwHgR+6cyHHpttPZBElJTHf83ZGsRNAmVCZCfUGA57WB4u4JA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "5.19.1", "version": "5.20.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.19.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.20.0.tgz",
"integrity": "sha512-pCq74rtlOVJfn4pLmdJj+eI4P7w2dugOnnTXpRilP/6n5b2aZiA4ulJlE0ddCbTPkfHmOL9BfaRgA8o+1rfdHw==", "integrity": "sha512-JVcaPXC940wOGpCOwuqQRTz6I9SaBK0c1BAyC1pcz9xBi+dzFgUu3G/p9GV1FhFs9OKpfSpIhQfUJE9y00zhqw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "5.19.1", "@prisma/debug": "5.20.0",
"@prisma/engines-version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3", "@prisma/engines-version": "5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284",
"@prisma/get-platform": "5.19.1" "@prisma/get-platform": "5.20.0"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
"version": "5.19.1", "version": "5.20.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.19.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.20.0.tgz",
"integrity": "sha512-sCeoJ+7yt0UjnR+AXZL7vXlg5eNxaFOwC23h0KvW1YIXUoa7+W2ZcAUhoEQBmJTW4GrFqCuZ8YSP0mkDa4k3Zg==", "integrity": "sha512-8/+CehTZZNzJlvuryRgc77hZCWrUDYd/PmlZ7p2yNXtmf2Una4BWnTbak3us6WVdqoz5wmptk6IhsXdG2v5fmA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "5.19.1" "@prisma/debug": "5.20.0"
} }
}, },
"node_modules/@redis/bloom": { "node_modules/@redis/bloom": {
@ -28820,13 +28820,13 @@
} }
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "5.19.1", "version": "5.20.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.19.1.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.20.0.tgz",
"integrity": "sha512-c5K9MiDaa+VAAyh1OiYk76PXOme9s3E992D7kvvIOhCrNsBQfy2mP2QAQtX0WNj140IgG++12kwZpYB9iIydNQ==", "integrity": "sha512-6obb3ucKgAnsGS9x9gLOe8qa51XxvJ3vLQtmyf52CTey1Qcez3A6W6ROH5HIz5Q5bW+0VpmZb8WBohieMFGpig==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/engines": "5.19.1" "@prisma/engines": "5.20.0"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"
@ -33666,10 +33666,11 @@
} }
}, },
"node_modules/webpack-bundle-analyzer": { "node_modules/webpack-bundle-analyzer": {
"version": "4.10.1", "version": "4.10.2",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz", "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz",
"integrity": "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==", "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@discoveryjs/json-ext": "0.5.7", "@discoveryjs/json-ext": "0.5.7",
"acorn": "^8.0.4", "acorn": "^8.0.4",
@ -33679,7 +33680,6 @@
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
"gzip-size": "^6.0.0", "gzip-size": "^6.0.0",
"html-escaper": "^2.0.2", "html-escaper": "^2.0.2",
"is-plain-object": "^5.0.0",
"opener": "^1.5.2", "opener": "^1.5.2",
"picocolors": "^1.0.0", "picocolors": "^1.0.0",
"sirv": "^2.0.3", "sirv": "^2.0.3",
@ -33713,15 +33713,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/webpack-bundle-analyzer/node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/webpack-bundle-analyzer/node_modules/ws": { "node_modules/webpack-bundle-analyzer/node_modules/ws": {
"version": "7.5.10", "version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.109.0", "version": "2.110.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",
@ -84,7 +84,7 @@
"@nestjs/platform-express": "10.1.3", "@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2", "@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0", "@nestjs/serve-static": "4.0.0",
"@prisma/client": "5.19.1", "@prisma/client": "5.20.0",
"@simplewebauthn/browser": "9.0.1", "@simplewebauthn/browser": "9.0.1",
"@simplewebauthn/server": "9.0.3", "@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "3.5.0", "@stripe/stripe-js": "3.5.0",
@ -128,7 +128,7 @@
"passport": "0.7.0", "passport": "0.7.0",
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
"prisma": "5.19.1", "prisma": "5.20.0",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rxjs": "7.5.6", "rxjs": "7.5.6",
"stripe": "15.11.0", "stripe": "15.11.0",
@ -202,7 +202,7 @@
"ts-node": "10.9.2", "ts-node": "10.9.2",
"tslib": "2.6.0", "tslib": "2.6.0",
"typescript": "5.5.3", "typescript": "5.5.3",
"webpack-bundle-analyzer": "4.10.1" "webpack-bundle-analyzer": "4.10.2"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"