Browse Source

Merge remote-tracking branch 'origin/main' into feat/storybook-holdings-table-component

pull/5697/head
David Requeno 3 weeks ago
parent
commit
ee32d2516e
  1. 1
      .github/FUNDING.yml
  2. 18
      CHANGELOG.md
  3. 5
      apps/api/src/app/app.module.ts
  4. 2
      apps/api/src/app/endpoints/public/public.controller.ts
  5. 38
      apps/api/src/app/import/import.service.ts
  6. 3
      apps/api/src/app/order/create-order.dto.ts
  7. 66
      apps/api/src/app/portfolio/portfolio.controller.ts
  8. 8
      apps/api/src/app/portfolio/portfolio.service.ts
  9. 16
      apps/api/src/main.ts
  10. 8
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  11. 6
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  12. 7
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  13. 2
      apps/client/src/app/pages/landing/landing-page.component.ts
  14. 105
      apps/client/src/app/pages/landing/landing-page.html
  15. 76
      apps/client/src/app/pages/landing/landing-page.scss
  16. 59
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  17. 35
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  18. 16
      apps/client/src/app/pages/portfolio/fire/fire-page.scss
  19. 2
      apps/client/src/locales/messages.de.xlf
  20. 2
      libs/common/src/lib/interfaces/portfolio-summary.interface.ts
  21. 2
      libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts
  22. 1
      libs/ui/src/lib/logo-carousel/index.ts
  23. 7
      libs/ui/src/lib/logo-carousel/interfaces/interfaces.ts
  24. 16
      libs/ui/src/lib/logo-carousel/logo-carousel.component.html
  25. 213
      libs/ui/src/lib/logo-carousel/logo-carousel.component.scss
  26. 13
      libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts
  27. 110
      libs/ui/src/lib/logo-carousel/logo-carousel.component.ts
  28. 76
      package-lock.json
  29. 6
      package.json
  30. 53
      test/import/ok/penthouse-apartment.json

1
.github/FUNDING.yml

@ -1 +1,2 @@
buy_me_a_coffee: ghostfolio buy_me_a_coffee: ghostfolio
github: ghostfolio

18
CHANGELOG.md

@ -5,11 +5,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## 2.208.0 - 2025-10-11
### Added
- Added support for configuring the safe withdrawal rate in the _FIRE_ section (experimental)
### Changed ### Changed
- Changed the _As seen in_ section on the landing page to an animated carousel
- Refactored `transactionCount` to `activitiesCount` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Refactored various components to use self-closing tags - Refactored various components to use self-closing tags
- Removed the deprecated endpoint `GET api/v1/portfolio/position/:dataSource/:symbol`
- Removed the deprecated endpoint `PUT api/v1/portfolio/position/:dataSource/:symbol/tags`
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `6.16.1` to `6.16.3`
### Fixed
- Fixed the server startup message to properly display IPv6 addresses
- Enabled IPv6 connectivity for _Redis_ in the job queue module by setting the address family
- Fixed an issue where importing custom asset profiles failed due to validation errors
## 2.207.0 - 2025-10-08 ## 2.207.0 - 2025-10-08

5
apps/api/src/app/app.module.ts

@ -71,9 +71,10 @@ import { UserModule } from './user/user.module';
BullModule.forRoot({ BullModule.forRoot({
redis: { redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10), db: parseInt(process.env.REDIS_DB ?? '0', 10),
family: 0,
host: process.env.REDIS_HOST, host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10), password: process.env.REDIS_PASSWORD,
password: process.env.REDIS_PASSWORD port: parseInt(process.env.REDIS_PORT ?? '6379', 10)
} }
}), }),
CacheModule, CacheModule,

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

@ -40,7 +40,7 @@ export class PublicController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublicPortfolio( public async getPublicPortfolio(
@Param('accessId') accessId @Param('accessId') accessId: string
): Promise<PublicPortfolioResponse> { ): Promise<PublicPortfolioResponse> {
const access = await this.accessService.access({ id: accessId }); const access = await this.accessService.access({ id: accessId });

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

@ -373,6 +373,7 @@ export class ImportService {
const assetProfiles = await this.validateActivities({ const assetProfiles = await this.validateActivities({
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport, maxActivitiesToImport,
user user
}); });
@ -698,10 +699,12 @@ export class ImportService {
private async validateActivities({ private async validateActivities({
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport, maxActivitiesToImport,
user user
}: { }: {
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
maxActivitiesToImport: number; maxActivitiesToImport: number;
user: UserWithSettings; user: UserWithSettings;
}) { }) {
@ -749,6 +752,41 @@ export class ImportService {
)?.[symbol] )?.[symbol]
}; };
if (!assetProfile?.name) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
if (assetProfileInImport) {
// Merge all fields of custom asset profiles into the validation object
Object.assign(assetProfile, {
assetClass: assetProfileInImport.assetClass,
assetSubClass: assetProfileInImport.assetSubClass,
comment: assetProfileInImport.comment,
countries: assetProfileInImport.countries,
currency: assetProfileInImport.currency,
cusip: assetProfileInImport.cusip,
dataSource: assetProfileInImport.dataSource,
figi: assetProfileInImport.figi,
figiComposite: assetProfileInImport.figiComposite,
figiShareClass: assetProfileInImport.figiShareClass,
holdings: assetProfileInImport.holdings,
isActive: assetProfileInImport.isActive,
isin: assetProfileInImport.isin,
name: assetProfileInImport.name,
scraperConfiguration: assetProfileInImport.scraperConfiguration,
sectors: assetProfileInImport.sectors,
symbol: assetProfileInImport.symbol,
symbolMapping: assetProfileInImport.symbolMapping,
url: assetProfileInImport.url
});
}
}
if ( if (
(dataSource !== 'MANUAL' && type === 'BUY') || (dataSource !== 'MANUAL' && type === 'BUY') ||
type === 'DIVIDEND' || type === 'DIVIDEND' ||

3
apps/api/src/app/order/create-order.dto.ts

@ -44,8 +44,7 @@ export class CreateOrderDto {
customCurrency?: string; customCurrency?: string;
@IsEnum(DataSource) @IsEnum(DataSource)
@IsOptional() dataSource: DataSource;
dataSource?: DataSource;
@IsISO8601() @IsISO8601()
@Validate(IsAfter1970Constraint) @Validate(IsAfter1970Constraint)

66
apps/api/src/app/portfolio/portfolio.controller.ts

@ -610,36 +610,6 @@ export class PortfolioController {
return performanceInformation; return performanceInformation;
} }
/**
* @deprecated
*/
@Get('position/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPosition(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getHolding({
dataSource,
impersonationId,
symbol,
userId: this.request.user.id
});
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return holding;
}
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport( public async getReport(
@ -699,40 +669,4 @@ export class PortfolioController {
userId: this.request.user.id userId: this.request.user.id
}); });
} }
/**
* @deprecated
*/
@HasPermission(permissions.updateOrder)
@Put('position/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updatePositionTags(
@Body() data: UpdateHoldingTagsDto,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
const holding = await this.portfolioService.getHolding({
dataSource,
impersonationId,
symbol,
userId: this.request.user.id
});
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
await this.portfolioService.updateTags({
dataSource,
impersonationId,
symbol,
tags: data.tags,
userId: this.request.user.id
});
}
} }

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

@ -778,6 +778,7 @@ export class PortfolioService {
if (activities.length === 0) { if (activities.length === 0) {
return { return {
activities: [], activities: [],
activitiesCount: 0,
averagePrice: undefined, averagePrice: undefined,
dataProviderInfo: undefined, dataProviderInfo: undefined,
dividendInBaseCurrency: undefined, dividendInBaseCurrency: undefined,
@ -802,7 +803,6 @@ export class PortfolioService {
quantity: undefined, quantity: undefined,
SymbolProfile: undefined, SymbolProfile: undefined,
tags: [], tags: [],
transactionCount: undefined,
value: undefined value: undefined
}; };
} }
@ -966,8 +966,8 @@ export class PortfolioService {
marketPriceMin, marketPriceMin,
SymbolProfile, SymbolProfile,
tags, tags,
transactionCount,
activities: activitiesOfHolding, activities: activitiesOfHolding,
activitiesCount: transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
@ -1070,6 +1070,7 @@ export class PortfolioService {
marketPriceMin, marketPriceMin,
SymbolProfile, SymbolProfile,
activities: [], activities: [],
activitiesCount: 0,
averagePrice: 0, averagePrice: 0,
dataProviderInfo: undefined, dataProviderInfo: undefined,
dividendInBaseCurrency: 0, dividendInBaseCurrency: 0,
@ -1095,7 +1096,6 @@ export class PortfolioService {
}, },
quantity: 0, quantity: 0,
tags: [], tags: [],
transactionCount: undefined,
value: 0 value: 0
}; };
} }
@ -2106,7 +2106,7 @@ export class PortfolioService {
.plus(fees) .plus(fees)
.toNumber(), .toNumber(),
interest: interest.toNumber(), interest: interest.toNumber(),
liabilities: liabilities.toNumber(), liabilitiesInBaseCurrency: liabilities.toNumber(),
totalInvestment: totalInvestment.toNumber(), totalInvestment: totalInvestment.toNumber(),
totalValueInBaseCurrency: netWorth totalValueInBaseCurrency: netWorth
}; };

16
apps/api/src/main.ts

@ -90,7 +90,21 @@ async function bootstrap() {
await app.listen(PORT, HOST, () => { await app.listen(PORT, HOST, () => {
logLogo(); logLogo();
Logger.log(`Listening at http://${HOST}:${PORT}`);
let address = app.getHttpServer().address();
if (typeof address === 'object') {
const addressObject = address;
let host = addressObject.address;
if (addressObject.family === 'IPv6') {
host = `[${addressObject.address}]`;
}
address = `${host}:${addressObject.port}`;
}
Logger.log(`Listening at http://${address}`);
Logger.log(''); Logger.log('');
}); });
} }

8
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -100,6 +100,7 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'holding-detail-dialog.html' templateUrl: 'holding-detail-dialog.html'
}) })
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit { export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public activitiesCount: number;
public activityForm: FormGroup; public activityForm: FormGroup;
public accounts: Account[]; public accounts: Account[];
public assetClass: string; public assetClass: string;
@ -151,8 +152,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public SymbolProfile: EnhancedSymbolProfile; public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[]; public tags: Tag[];
public tagsAvailable: Tag[]; public tagsAvailable: Tag[];
public totalItems: number;
public transactionCount: number;
public user: User; public user: User;
public value: number; public value: number;
@ -261,6 +260,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe( .subscribe(
({ ({
activitiesCount,
averagePrice, averagePrice,
dataProviderInfo, dataProviderInfo,
dividendInBaseCurrency, dividendInBaseCurrency,
@ -279,9 +279,9 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
quantity, quantity,
SymbolProfile, SymbolProfile,
tags, tags,
transactionCount,
value value
}) => { }) => {
this.activitiesCount = activitiesCount;
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
if ( if (
@ -429,8 +429,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.activityForm.setValue({ tags: this.tags }, { emitEvent: false }); this.activityForm.setValue({ tags: this.tags }, { emitEvent: false });
this.transactionCount = transactionCount;
this.totalItems = transactionCount;
this.value = value; this.value = value;
if (SymbolProfile?.assetClass) { if (SymbolProfile?.assetClass) {

6
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -223,9 +223,9 @@
<gf-value <gf-value
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="transactionCount" [value]="activitiesCount"
> >
@if (transactionCount === 1) { @if (activitiesCount === 1) {
<ng-container i18n>Activity</ng-container> <ng-container i18n>Activity</ng-container>
} @else { } @else {
<ng-container i18n>Activities</ng-container> <ng-container i18n>Activities</ng-container>
@ -363,7 +363,7 @@
[sortColumn]="sortColumn" [sortColumn]="sortColumn"
[sortDirection]="sortDirection" [sortDirection]="sortDirection"
[sortDisabled]="true" [sortDisabled]="true"
[totalItems]="totalItems" [totalItems]="activitiesCount"
(activityToClone)="onCloneActivity($event)" (activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)" (activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()" (export)="onExport()"

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

@ -242,7 +242,10 @@
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Liabilities</div> <div class="flex-grow-1 text-truncate" i18n>Liabilities</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
@if (summary?.liabilities || summary?.liabilities === 0) { @if (
summary?.liabilitiesInBaseCurrency ||
summary?.liabilitiesInBaseCurrency === 0
) {
<span class="mr-1">-</span> <span class="mr-1">-</span>
} }
<gf-value <gf-value
@ -250,7 +253,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.liabilities" [value]="isLoading ? undefined : summary?.liabilitiesInBaseCurrency"
/> />
</div> </div>
</div> </div>

2
apps/client/src/app/pages/landing/landing-page.component.ts

@ -4,6 +4,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfCarouselComponent } from '@ghostfolio/ui/carousel'; import { GfCarouselComponent } from '@ghostfolio/ui/carousel';
import { GfLogoComponent } from '@ghostfolio/ui/logo'; import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { GfLogoCarouselComponent } from '@ghostfolio/ui/logo-carousel';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart'; import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart';
@ -27,6 +28,7 @@ import { Subject } from 'rxjs';
imports: [ imports: [
CommonModule, CommonModule,
GfCarouselComponent, GfCarouselComponent,
GfLogoCarouselComponent,
GfLogoComponent, GfLogoComponent,
GfValueComponent, GfValueComponent,
GfWorldMapChartComponent, GfWorldMapChartComponent,

105
apps/client/src/app/pages/landing/landing-page.html

@ -114,109 +114,8 @@
<div class="col-12 text-center text-muted"> <div class="col-12 text-center text-muted">
<small i18n>As seen in</small> <small i18n>As seen in</small>
</div> </div>
<div class="col-md-3 d-flex justify-content-center my-1"> <div class="col-12">
<a <gf-logo-carousel class="py-3" />
class="d-block logo logo-alternative-to mask"
href="https://alternativeto.net"
target="_blank"
title="AlternativeTo - Crowdsourced software recommendations"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-awesome"
href="https://github.com/awesome-selfhosted/awesome-selfhosted"
target="_blank"
title="Awesome-Selfhosted: A list of Free Software network services and web applications which can be hosted on your own servers"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-dev-community mask"
href="https://dev.to"
target="_blank"
title="DEV Community - A constructive and inclusive social network for software developers"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-hacker-news mask"
href="https://news.ycombinator.com"
target="_blank"
title="Hacker News"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-openalternative mask"
href="https://openalternative.co"
target="_blank"
title="OpenAlternative: Open Source Alternatives to Popular Software"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-privacy-tools mask"
href="https://www.privacytools.io"
target="_blank"
title="Privacy Tools: Software Alternatives and Encryption"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-product-hunt"
href="https://www.producthunt.com"
target="_blank"
title="Product Hunt – The best new products in tech."
></a>
</div>
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-reddit mask"
href="https://www.reddit.com"
target="_blank"
title="Reddit - Dive into anything"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-sackgeld mask"
href="https://www.sackgeld.com"
target="_blank"
title="Sackgeld.com – Apps für ein höheres Sackgeld"
></a>
</div>
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-selfh-st mask"
href="https://selfh.st"
target="_blank"
title="selfh.st — Self-hosted content and software"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-sourceforge mask"
href="https://sourceforge.net"
target="_blank"
title="SourceForge: The Complete Open-Source and Business Software Platform"
></a>
</div>
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-umbrel mask"
href="https://umbrel.com"
target="_blank"
title="Umbrel — A personal server OS for self-hosting"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-unraid mask"
href="https://unraid.net"
target="_blank"
title="Unraid | Unleash Your Hardware"
></a>
</div> </div>
</div> </div>

76
apps/client/src/app/pages/landing/landing-page.scss

@ -34,69 +34,6 @@
&.logo-agplv3 { &.logo-agplv3 {
mask-image: url('/assets/images/logo-AGPLv3.svg'); mask-image: url('/assets/images/logo-AGPLv3.svg');
} }
&.logo-alternative-to {
mask-image: url('/assets/images/logo-alternative-to.svg');
}
&.logo-awesome {
background-image: url('/assets/images/logo-awesome.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-dev-community {
mask-image: url('/assets/images/logo-dev-community.svg');
}
&.logo-hacker-news {
mask-image: url('/assets/images/logo-hacker-news.svg');
}
&.logo-openalternative {
mask-image: url('/assets/images/logo-openalternative.svg');
}
&.logo-privacy-tools {
mask-image: url('/assets/images/logo-privacy-tools.svg');
}
&.logo-product-hunt {
background-image: url('/assets/images/logo-product-hunt.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-reddit {
mask-image: url('/assets/images/logo-reddit.svg');
max-height: 1rem;
}
&.logo-sackgeld {
mask-image: url('/assets/images/logo-sackgeld.png');
}
&.logo-selfh-st {
mask-image: url('/assets/images/logo-selfh-st.svg');
max-height: 1.25rem;
}
&.logo-sourceforge {
mask-image: url('/assets/images/logo-sourceforge.svg');
}
&.logo-umbrel {
mask-image: url('/assets/images/logo-umbrel.svg');
max-height: 1.5rem;
}
&.logo-unraid {
mask-image: url('/assets/images/logo-unraid.svg');
}
} }
.outro-inner-container { .outro-inner-container {
@ -128,18 +65,7 @@
} }
.logo { .logo {
&.logo-agplv3, &.logo-agplv3 {
&.logo-alternative-to,
&.logo-dev-community,
&.logo-hacker-news,
&.logo-openalternative,
&.logo-privacy-tools,
&.logo-reddit,
&.logo-sackgeld,
&.logo-selfh-st,
&.logo-sourceforge,
&.logo-umbrel,
&.logo-unraid {
background-color: rgba(var(--light-primary-text)); background-color: rgba(var(--light-primary-text));
} }
} }

59
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -7,8 +7,10 @@ import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { NgStyle } from '@angular/common'; import { CommonModule, NgStyle } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { FormControl } from '@angular/forms';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -17,11 +19,14 @@ import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
imports: [ imports: [
CommonModule,
FormsModule,
GfFireCalculatorComponent, GfFireCalculatorComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfValueComponent, GfValueComponent,
NgStyle, NgStyle,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule,
ReactiveFormsModule
], ],
selector: 'gf-fire-page', selector: 'gf-fire-page',
styleUrls: ['./fire-page.scss'], styleUrls: ['./fire-page.scss'],
@ -33,6 +38,8 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public isLoading = false; public isLoading = false;
public safeWithdrawalRateControl = new FormControl<number>(undefined);
public safeWithdrawalRateOptions = [0.025, 0.03, 0.035, 0.04, 0.045];
public user: User; public user: User;
public withdrawalRatePerMonth: Big; public withdrawalRatePerMonth: Big;
public withdrawalRatePerYear: Big; public withdrawalRatePerYear: Big;
@ -70,11 +77,7 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
}; };
} }
this.withdrawalRatePerYear = Big( this.calculateWithdrawalRates();
this.fireWealth.today.valueInBaseCurrency
).mul(this.user.settings.safeWithdrawalRate);
this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12);
this.isLoading = false; this.isLoading = false;
@ -88,6 +91,12 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!impersonationId; this.hasImpersonationId = !!impersonationId;
}); });
this.safeWithdrawalRateControl.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((value) => {
this.onSafeWithdrawalRateChange(Number(value));
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
@ -102,6 +111,13 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
permissions.updateUserSettings permissions.updateUserSettings
); );
this.safeWithdrawalRateControl.setValue(
this.user.settings.safeWithdrawalRate,
{ emitEvent: false }
);
this.calculateWithdrawalRates();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
@ -141,6 +157,25 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
}); });
}); });
} }
public onSafeWithdrawalRateChange(safeWithdrawalRate: number) {
this.dataService
.putUserSetting({ safeWithdrawalRate })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.calculateWithdrawalRates();
this.changeDetectorRef.markForCheck();
});
});
}
public onSavingsRateChange(savingsRate: number) { public onSavingsRateChange(savingsRate: number) {
this.dataService this.dataService
.putUserSetting({ savingsRate }) .putUserSetting({ savingsRate })
@ -180,4 +215,14 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private calculateWithdrawalRates() {
if (this.fireWealth && this.user?.settings?.safeWithdrawalRate) {
this.withdrawalRatePerYear = new Big(
this.fireWealth.today.valueInBaseCurrency
).mul(this.user.settings.safeWithdrawalRate);
this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12);
}
}
} }

35
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -105,15 +105,32 @@
</span> </span>
<ng-container>&nbsp;</ng-container> <ng-container>&nbsp;</ng-container>
<ng-container i18n>and a safe withdrawal rate (SWR) of</ng-container> <ng-container i18n>and a safe withdrawal rate (SWR) of</ng-container>
<ng-container>&nbsp;</ng-container> @if (
<span class="font-weight-bold" !hasImpersonationId &&
><gf-value hasPermissionToUpdateUserSettings &&
class="d-inline-block" user?.settings?.isExperimentalFeatures
[isPercent]="true" ) {
[locale]="user?.settings?.locale" <select
[precision]="1" class="border-0 cursor-pointer d-inline-block font-weight-bold safe-withdrawal-rate-select"
[value]="user?.settings?.safeWithdrawalRate" /></span [formControl]="safeWithdrawalRateControl"
>. >
@for (rate of safeWithdrawalRateOptions; track rate) {
<option [value]="rate">
{{ rate | percent: '1.1-1' }}
</option>
}</select
>.
} @else {
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isPercent]="true"
[locale]="user?.settings?.locale"
[precision]="1"
[value]="user?.settings?.safeWithdrawalRate" /></span
>.
}
</div> </div>
} }
</div> </div>

16
apps/client/src/app/pages/portfolio/fire/fire-page.scss

@ -1,3 +1,19 @@
:host { :host {
display: block; display: block;
.safe-withdrawal-rate-select {
background-color: transparent;
color: rgb(var(--dark-primary-text));
&:focus {
box-shadow: none;
outline: 0;
}
}
}
:host-context(.theme-dark) {
.safe-withdrawal-rate-select {
color: rgb(var(--light-primary-text));
}
} }

2
apps/client/src/locales/messages.de.xlf

@ -5409,7 +5409,7 @@
</trans-unit> </trans-unit>
<trans-unit id="3302046820145091217" datatype="html"> <trans-unit id="3302046820145091217" datatype="html">
<source>,</source> <source>,</source>
<target state="translated">entnehmen,</target> <target state="translated">&nbsp;entnehmen,</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context>
<context context-type="linenumber">93</context> <context context-type="linenumber">93</context>

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

@ -21,7 +21,7 @@ export interface PortfolioSummary extends PortfolioPerformance {
grossPerformance: number; grossPerformance: number;
grossPerformanceWithCurrencyEffect: number; grossPerformanceWithCurrencyEffect: number;
interest: number; interest: number;
liabilities: number; liabilitiesInBaseCurrency: number;
totalBuy: number; totalBuy: number;
totalSell: number; totalSell: number;
totalValueInBaseCurrency?: number; totalValueInBaseCurrency?: number;

2
libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts

@ -10,6 +10,7 @@ import { Tag } from '@prisma/client';
export interface PortfolioHoldingResponse { export interface PortfolioHoldingResponse {
activities: Activity[]; activities: Activity[];
activitiesCount: number;
averagePrice: number; averagePrice: number;
dataProviderInfo: DataProviderInfo; dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number; dividendInBaseCurrency: number;
@ -34,6 +35,5 @@ export interface PortfolioHoldingResponse {
quantity: number; quantity: number;
SymbolProfile: EnhancedSymbolProfile; SymbolProfile: EnhancedSymbolProfile;
tags: Tag[]; tags: Tag[];
transactionCount: number;
value: number; value: number;
} }

1
libs/ui/src/lib/logo-carousel/index.ts

@ -0,0 +1 @@
export * from './logo-carousel.component';

7
libs/ui/src/lib/logo-carousel/interfaces/interfaces.ts

@ -0,0 +1,7 @@
export interface LogoItem {
className: string;
isMask?: boolean;
name: string;
title: string;
url: string;
}

16
libs/ui/src/lib/logo-carousel/logo-carousel.component.html

@ -0,0 +1,16 @@
<div class="logo-carousel-container overflow-hidden position-relative w-100">
<div class="align-items-center d-flex logo-carousel-track">
@for (logo of logosRepeated; track $index) {
<div class="logo-carousel-item">
<a
class="d-block logo"
target="_blank"
[attr.aria-label]="logo.name"
[class]="logo.className + (logo.isMask ? ' mask' : '')"
[href]="logo.url"
[title]="logo.title"
></a>
</div>
}
</div>
</div>

213
libs/ui/src/lib/logo-carousel/logo-carousel.component.scss

@ -0,0 +1,213 @@
:host {
display: block;
overflow: hidden;
position: relative;
width: 100%;
.logo-carousel-container {
&::before,
&::after {
content: '';
height: 100%;
pointer-events: none;
position: absolute;
top: 0;
width: 100px;
z-index: 2;
}
&::before {
background: linear-gradient(
to right,
var(--light-background) 0%,
rgba(var(--palette-background-background), 0) 100%
);
left: 0;
}
&::after {
background: linear-gradient(
to left,
var(--light-background) 0%,
rgba(var(--palette-background-background), 0) 100%
);
right: 0;
}
@media (max-width: 768px) {
&::before,
&::after {
width: 50px;
}
}
@media (max-width: 576px) {
&::before,
&::after {
width: 30px;
}
}
.logo-carousel-track {
animation: scroll 60s linear infinite;
width: fit-content;
&:hover {
animation-play-state: paused;
}
.logo-carousel-item {
flex-shrink: 0;
min-width: 200px;
padding: 0 2rem;
@media (max-width: 768px) {
min-width: 150px;
padding: 0 1.5rem;
}
@media (max-width: 576px) {
min-width: 120px;
padding: 0 1rem;
}
.logo {
height: 3rem;
transition:
opacity 0.3s ease,
transform 0.3s ease;
width: 7.5rem;
&:hover {
opacity: 0.8;
}
&.mask {
background-color: rgba(var(--dark-secondary-text));
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
}
&.logo-alternative-to {
mask-image: url('/assets/images/logo-alternative-to.svg');
}
&.logo-awesome {
background-image: url('/assets/images/logo-awesome.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-dev-community {
mask-image: url('/assets/images/logo-dev-community.svg');
}
&.logo-hacker-news {
mask-image: url('/assets/images/logo-hacker-news.svg');
}
&.logo-openalternative {
mask-image: url('/assets/images/logo-openalternative.svg');
}
&.logo-privacy-tools {
mask-image: url('/assets/images/logo-privacy-tools.svg');
}
&.logo-product-hunt {
background-image: url('/assets/images/logo-product-hunt.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-reddit {
mask-image: url('/assets/images/logo-reddit.svg');
max-height: 1rem;
}
&.logo-sackgeld {
mask-image: url('/assets/images/logo-sackgeld.png');
}
&.logo-selfh-st {
mask-image: url('/assets/images/logo-selfh-st.svg');
max-height: 1.25rem;
}
&.logo-sourceforge {
mask-image: url('/assets/images/logo-sourceforge.svg');
}
&.logo-umbrel {
mask-image: url('/assets/images/logo-umbrel.svg');
max-height: 1.5rem;
}
&.logo-unraid {
mask-image: url('/assets/images/logo-unraid.svg');
}
@media (max-width: 768px) {
height: 2.5rem;
width: 6rem;
}
@media (max-width: 576px) {
height: 2rem;
width: 5rem;
}
}
}
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
}
}
}
:host-context(.theme-dark) {
.logo-carousel-container {
&::before {
background: linear-gradient(
to right,
var(--dark-background) 0%,
rgba(var(--palette-background-background-dark), 0) 100%
);
}
&::after {
background: linear-gradient(
to left,
var(--dark-background) 0%,
rgba(var(--palette-background-background-dark), 0) 100%
);
}
.logo {
&.logo-alternative-to,
&.logo-dev-community,
&.logo-hacker-news,
&.logo-openalternative,
&.logo-privacy-tools,
&.logo-reddit,
&.logo-sackgeld,
&.logo-selfh-st,
&.logo-sourceforge,
&.logo-umbrel,
&.logo-unraid {
background-color: rgba(var(--light-primary-text));
}
}
}
}

13
libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { GfLogoCarouselComponent } from './logo-carousel.component';
const meta: Meta<GfLogoCarouselComponent> = {
title: 'Logo Carousel',
component: GfLogoCarouselComponent
};
export default meta;
type Story = StoryObj<GfLogoCarouselComponent>;
export const Default: Story = {};

110
libs/ui/src/lib/logo-carousel/logo-carousel.component.ts

@ -0,0 +1,110 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { LogoItem } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
selector: 'gf-logo-carousel',
styleUrls: ['./logo-carousel.component.scss'],
templateUrl: './logo-carousel.component.html'
})
export class GfLogoCarouselComponent {
public readonly logos: LogoItem[] = [
{
className: 'logo-alternative-to',
isMask: true,
name: 'AlternativeTo',
title: 'AlternativeTo - Crowdsourced software recommendations',
url: 'https://alternativeto.net'
},
{
className: 'logo-awesome',
name: 'Awesome Selfhosted',
title:
'Awesome-Selfhosted: A list of Free Software network services and web applications which can be hosted on your own servers',
url: 'https://github.com/awesome-selfhosted/awesome-selfhosted'
},
{
className: 'logo-dev-community',
isMask: true,
name: 'DEV Community',
title:
'DEV Community - A constructive and inclusive social network for software developers',
url: 'https://dev.to'
},
{
className: 'logo-hacker-news',
isMask: true,
name: 'Hacker News',
title: 'Hacker News',
url: 'https://news.ycombinator.com'
},
{
className: 'logo-openalternative',
isMask: true,
name: 'OpenAlternative',
title: 'OpenAlternative: Open Source Alternatives to Popular Software',
url: 'https://openalternative.co'
},
{
className: 'logo-privacy-tools',
isMask: true,
name: 'Privacy Tools',
title: 'Privacy Tools: Software Alternatives and Encryption',
url: 'https://www.privacytools.io'
},
{
className: 'logo-product-hunt',
name: 'Product Hunt',
title: 'Product Hunt – The best new products in tech.',
url: 'https://www.producthunt.com'
},
{
className: 'logo-reddit',
isMask: true,
name: 'Reddit',
title: 'Reddit - Dive into anything',
url: 'https://www.reddit.com'
},
{
className: 'logo-sackgeld',
isMask: true,
name: 'Sackgeld',
title: 'Sackgeld.com – Apps für ein höheres Sackgeld',
url: 'https://www.sackgeld.com'
},
{
className: 'logo-selfh-st',
isMask: true,
name: 'selfh.st',
title: 'selfh.st — Self-hosted content and software',
url: 'https://selfh.st'
},
{
className: 'logo-sourceforge',
isMask: true,
name: 'SourceForge',
title:
'SourceForge: The Complete Open-Source and Business Software Platform',
url: 'https://sourceforge.net'
},
{
className: 'logo-umbrel',
isMask: true,
name: 'Umbrel',
title: 'Umbrel — A personal server OS for self-hosting',
url: 'https://umbrel.com'
},
{
className: 'logo-unraid',
isMask: true,
name: 'Unraid',
title: 'Unraid | Unleash Your Hardware',
url: 'https://unraid.net'
}
];
public readonly logosRepeated = [...this.logos, ...this.logos];
}

76
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.207.0", "version": "2.208.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.207.0", "version": "2.208.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -44,7 +44,7 @@
"@nestjs/schedule": "6.0.0", "@nestjs/schedule": "6.0.0",
"@nestjs/serve-static": "5.0.3", "@nestjs/serve-static": "5.0.3",
"@openrouter/ai-sdk-provider": "0.7.2", "@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.16.3", "@prisma/client": "6.17.1",
"@simplewebauthn/browser": "13.1.0", "@simplewebauthn/browser": "13.1.0",
"@simplewebauthn/server": "13.1.1", "@simplewebauthn/server": "13.1.1",
"@stripe/stripe-js": "7.9.0", "@stripe/stripe-js": "7.9.0",
@ -149,7 +149,7 @@
"nx": "21.5.1", "nx": "21.5.1",
"prettier": "3.6.2", "prettier": "3.6.2",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.16.3", "prisma": "6.17.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"replace-in-file": "8.3.0", "replace-in-file": "8.3.0",
@ -11960,9 +11960,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "6.16.3", "version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.17.1.tgz",
"integrity": "sha512-JfNfAtXG+/lIopsvoZlZiH2k5yNx87mcTS4t9/S5oufM1nKdXYxOvpDC1XoTCFBa5cQh7uXnbMPsmZrwZY80xw==", "integrity": "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@ -11982,9 +11982,9 @@
} }
}, },
"node_modules/@prisma/config": { "node_modules/@prisma/config": {
"version": "6.16.3", "version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.17.1.tgz",
"integrity": "sha512-VlsLnG4oOuKGGMToEeVaRhoTBZu5H3q51jTQXb/diRags3WV0+BQK5MolJTtP6G7COlzoXmWeS11rNBtvg+qFQ==", "integrity": "sha512-fs8wY6DsvOCzuiyWVckrVs1LOcbY4LZNz8ki4uUIQ28jCCzojTGqdLhN2Jl5lDnC1yI8/gNIKpsWDM8pLhOdwA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -11995,53 +11995,53 @@
} }
}, },
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "6.16.3", "version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.17.1.tgz",
"integrity": "sha512-89DdqWtdKd7qoc9/qJCKLTazj3W3zPEiz0hc7HfZdpjzm21c7orOUB5oHWJsG+4KbV4cWU5pefq3CuDVYF9vgA==", "integrity": "sha512-Vf7Tt5Wh9XcndpbmeotuqOMLWPTjEKCsgojxXP2oxE1/xYe7PtnP76hsouG9vis6fctX+TxgmwxTuYi/+xc7dQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "6.16.3", "version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.17.1.tgz",
"integrity": "sha512-b+Rl4nzQDcoqe6RIpSHv8f5lLnwdDGvXhHjGDiokObguAAv/O1KaX1Oc69mBW/GFWKQpCkOraobLjU6s1h8HGg==", "integrity": "sha512-D95Ik3GYZkqZ8lSR4EyFOJ/tR33FcYRP8kK61o+WMsyD10UfJwd7+YielflHfKwiGodcqKqoraWw8ElAgMDbPw==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.16.3", "@prisma/debug": "6.17.1",
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", "@prisma/engines-version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac",
"@prisma/fetch-engine": "6.16.3", "@prisma/fetch-engine": "6.17.1",
"@prisma/get-platform": "6.16.3" "@prisma/get-platform": "6.17.1"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", "version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac.tgz",
"integrity": "sha512-fftRmosBex48Ph1v2ll1FrPpirwtPZpNkE5CDCY1Lw2SD2ctyrLlVlHiuxDAAlALwWBOkPbAll4+EaqdGuMhJw==", "integrity": "sha512-17140E3huOuD9lMdJ9+SF/juOf3WR3sTJMVyyenzqUPbuH+89nPhSWcrY+Mf7tmSs6HvaO+7S+HkELinn6bhdg==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "6.16.3", "version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.17.1.tgz",
"integrity": "sha512-bUoRIkVaI+CCaVGrSfcKev0/Mk4ateubqWqGZvQ9uCqFv2ENwWIR3OeNuGin96nZn5+SkebcD7RGgKr/+mJelw==", "integrity": "sha512-AYZiHOs184qkDMiTeshyJCtyL4yERkjfTkJiSJdYuSfc24m94lTNL5+GFinZ6vVz+ktX4NJzHKn1zIFzGTWrWg==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.16.3", "@prisma/debug": "6.17.1",
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", "@prisma/engines-version": "6.17.1-1.272a37d34178c2894197e17273bf937f25acdeac",
"@prisma/get-platform": "6.16.3" "@prisma/get-platform": "6.17.1"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
"version": "6.16.3", "version": "6.17.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.17.1.tgz",
"integrity": "sha512-X1LxiFXinJ4iQehrodGp0f66Dv6cDL0GbRlcCoLtSu6f4Wi+hgo7eND/afIs5029GQLgNWKZ46vn8hjyXTsHLA==", "integrity": "sha512-AKEn6fsfz0r482S5KRDFlIGEaq9wLNcgalD1adL+fPcFFblIKs1sD81kY/utrHdqKuVC6E1XSRpegDK3ZLL4Qg==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.16.3" "@prisma/debug": "6.17.1"
} }
}, },
"node_modules/@redis/client": { "node_modules/@redis/client": {
@ -35747,15 +35747,15 @@
} }
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "6.16.3", "version": "6.17.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.3.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.17.1.tgz",
"integrity": "sha512-4tJq3KB9WRshH5+QmzOLV54YMkNlKOtLKaSdvraI5kC/axF47HuOw6zDM8xrxJ6s9o2WodY654On4XKkrobQdQ==", "integrity": "sha512-ac6h0sM1Tg3zu8NInY+qhP/S9KhENVaw9n1BrGKQVFu05JT5yT5Qqqmb8tMRIE3ZXvVj4xcRA5yfrsy4X7Yy5g==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/config": "6.16.3", "@prisma/config": "6.17.1",
"@prisma/engines": "6.16.3" "@prisma/engines": "6.17.1"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"

6
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.207.0", "version": "2.208.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",
@ -90,7 +90,7 @@
"@nestjs/schedule": "6.0.0", "@nestjs/schedule": "6.0.0",
"@nestjs/serve-static": "5.0.3", "@nestjs/serve-static": "5.0.3",
"@openrouter/ai-sdk-provider": "0.7.2", "@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.16.3", "@prisma/client": "6.17.1",
"@simplewebauthn/browser": "13.1.0", "@simplewebauthn/browser": "13.1.0",
"@simplewebauthn/server": "13.1.1", "@simplewebauthn/server": "13.1.1",
"@stripe/stripe-js": "7.9.0", "@stripe/stripe-js": "7.9.0",
@ -195,7 +195,7 @@
"nx": "21.5.1", "nx": "21.5.1",
"prettier": "3.6.2", "prettier": "3.6.2",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.16.3", "prisma": "6.17.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"replace-in-file": "8.3.0", "replace-in-file": "8.3.0",

53
test/import/ok/penthouse-apartment.json

@ -0,0 +1,53 @@
{
"meta": {
"date": "2023-02-05T00:00:00.000Z",
"version": "dev"
},
"accounts": [],
"assetProfiles": [
{
"assetClass": null,
"assetSubClass": null,
"comment": null,
"countries": [],
"currency": "USD",
"cusip": null,
"dataSource": "MANUAL",
"figi": null,
"figiComposite": null,
"figiShareClass": null,
"holdings": [],
"isActive": true,
"isin": null,
"marketData": [],
"name": "Penthouse Apartment",
"scraperConfiguration": null,
"sectors": [],
"symbol": "7e91b7d4-1430-4212-8380-289a06c9bbc1",
"symbolMapping": {},
"url": null
}
],
"platforms": [],
"tags": [],
"activities": [
{
"accountId": null,
"comment": null,
"currency": "USD",
"dataSource": "MANUAL",
"date": "2022-01-01T00:00:00.000Z",
"fee": 0,
"quantity": 1,
"symbol": "7e91b7d4-1430-4212-8380-289a06c9bbc1",
"tags": [],
"type": "BUY",
"unitPrice": 500000,
}
],
"user": {
"settings": {
"currency": "USD"
}
}
}
Loading…
Cancel
Save