Browse Source

Merge commit '65941eb62e3e66b974de45772abde52466647a76' into feature/oidc-user-link

pull/6075/head
Germán Martín 4 weeks ago
parent
commit
6077597fdc
  1. 3
      .config/prisma.ts
  2. 2
      .env.dev
  3. 2
      .env.example
  4. 30
      CHANGELOG.md
  5. 40
      README.md
  6. 5
      apps/api/src/app/access/access.controller.ts
  7. 316
      apps/api/src/app/admin/admin.service.ts
  8. 3
      apps/api/src/app/endpoints/public/public.controller.ts
  9. 4
      apps/api/src/app/endpoints/sitemap/sitemap.service.ts
  10. 12
      apps/api/src/app/portfolio/portfolio.controller.ts
  11. 7
      apps/api/src/app/user/user.service.ts
  12. 109
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  13. 4
      apps/api/src/middlewares/html-template.middleware.ts
  14. 7
      apps/api/src/services/data-provider/data-provider.service.ts
  15. 6
      apps/api/src/services/prisma/prisma.service.ts
  16. 2
      apps/client/src/app/app.component.ts
  17. 7
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  18. 68
      apps/client/src/app/components/header/header.component.html
  19. 13
      apps/client/src/app/components/header/header.component.scss
  20. 21
      apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.component.ts
  21. 281
      apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html
  22. 26
      apps/client/src/app/pages/blog/blog-page.html
  23. 9
      apps/client/src/app/pages/blog/blog-page.routes.ts
  24. 124
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  25. 5
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  26. 3
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts
  27. BIN
      apps/client/src/assets/images/blog/ghostfolio-3.jpg
  28. 7
      apps/client/src/assets/oss-friends.json
  29. 83
      apps/client/src/styles.scss
  30. 516
      apps/client/src/styles/theme.scss
  31. 1
      libs/common/jest.config.ts
  32. 3
      libs/common/src/test-setup.ts
  33. 4810
      package-lock.json
  34. 51
      package.json
  35. 1
      prisma/schema.prisma
  36. 7
      prisma/seed.mts

3
.config/prisma.ts

@ -6,6 +6,9 @@ import { join } from 'node:path';
expand(config({ quiet: true }));
export default defineConfig({
datasource: {
url: process.env.DATABASE_URL ?? ''
},
migrations: {
path: join(__dirname, '..', 'prisma', 'migrations'),
seed: `node ${join(__dirname, '..', 'prisma', 'seed.mts')}`

2
.env.dev

@ -12,7 +12,7 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
# DEVELOPMENT

2
.env.example

@ -12,5 +12,5 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

30
CHANGELOG.md

@ -5,7 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.255.0 - 2026-03-20
## 3.0.0 - 2026-04-23
### Added
- Added a blog post: _Announcing Ghostfolio 3.0_
### Changed
- Migrated from _Material Design_ 2 to _Material Design_ 3
- Moved the total amount, change and performance with currency effects on the analysis page from experimental to general availability
- Refreshed the cryptocurrencies list
- Upgraded `countup.js` from version `2.9.0` to `2.10.0`
- Upgraded `jsonpath` from version `1.2.1` to `1.3.0`
- Upgraded `nestjs` from version `11.1.14` to `11.1.19`
- Upgraded `ngx-markdown` from version `21.1.0` to `21.2.0`
- Upgraded `Nx` from version `22.6.4` to `22.6.5`
- Upgraded `prisma` from version `6.19.0` to `7.7.0`
### Todo
- **Breaking Change**: The `sslmode=prefer` parameter in `DATABASE_URL` is no longer supported. Please update your environment variables (see `.env`) to use `sslmode=require` if _SSL_ is enabled or remove the `sslmode` parameter entirely if _SSL_ is not used.
## 2.255.0 - 2026-04-20
### Changed
@ -22,7 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed the missing value column of the accounts table component on mobile
## 2.254.0 - 2026-03-10
## 2.254.0 - 2026-04-10
### Added
@ -40,7 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the style of the activity type component
## 2.253.0 - 2026-03-06
## 2.253.0 - 2026-04-06
### Added
@ -60,7 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed the allocations by ETF provider chart on the allocations page in the _Presenter View_
- Fixed the allocations by platform chart on the allocations page in the _Presenter View_
## 2.252.0 - 2026-03-02
## 2.252.0 - 2026-04-02
### Added

40
README.md

@ -85,26 +85,26 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables
| Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
| Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
#### OpenID Connect OIDC (Experimental)

5
apps/api/src/app/access/access.controller.ts

@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { Access } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -75,7 +76,7 @@ export class AccessController {
): Promise<AccessModel> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
this.request.user.subscription.type === SubscriptionType.Basic
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -130,7 +131,7 @@ export class AccessController {
): Promise<AccessModel> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
this.request.user.subscription.type === SubscriptionType.Basic
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),

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

@ -36,7 +36,6 @@ import {
BadRequestException,
HttpException,
Injectable,
Logger,
NotFoundException
} from '@nestjs/common';
import {
@ -44,7 +43,6 @@ import {
AssetSubClass,
DataSource,
Prisma,
PrismaClient,
Property,
SymbolProfile
} from '@prisma/client';
@ -280,186 +278,178 @@ export class AdminService {
const extendedPrismaClient = this.getExtendedPrismaClient();
try {
const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
skip,
take,
where,
orderBy: [...orderBy, { id: sortDirection }],
select: {
_count: {
select: {
activities: true,
watchedBy: true
}
},
activities: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
assetClass: true,
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
id: true,
isActive: true,
isUsedByUsersWithSubscription: true,
name: true,
scraperConfiguration: true,
sectors: true,
symbol: true,
SymbolProfileOverrides: true
}
}),
this.prismaService.symbolProfile.count({ where })
]);
const assetProfiles = symbolProfileResult[0];
let count = symbolProfileResult[1];
const lastMarketPrices = await this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
skip,
take,
where,
orderBy: [...orderBy, { id: sortDirection }],
select: {
_count: {
select: {
activities: true,
watchedBy: true
}
},
activities: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
assetClass: true,
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
marketPrice: true,
symbol: true
id: true,
isActive: true,
isUsedByUsersWithSubscription: true,
name: true,
scraperConfiguration: true,
sectors: true,
symbol: true,
SymbolProfileOverrides: true
}
}),
this.prismaService.symbolProfile.count({ where })
]);
const assetProfiles = symbolProfileResult[0];
let count = symbolProfileResult[1];
const lastMarketPrices = await this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: assetProfiles.map(({ dataSource }) => {
return dataSource;
})
},
where: {
dataSource: {
in: assetProfiles.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: assetProfiles.map(({ symbol }) => {
return symbol;
})
}
symbol: {
in: assetProfiles.map(({ symbol }) => {
return symbol;
})
}
});
}
});
const lastMarketPriceMap = new Map<string, number>();
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(
async ({
_count,
activities,
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
id,
isActive,
isUsedByUsersWithSubscription,
name,
sectors,
symbol,
SymbolProfileOverrides
}) => {
let countriesCount = countries ? Object.keys(countries).length : 0;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
let sectorsCount = sectors ? Object.keys(sectors).length : 0;
if (SymbolProfileOverrides) {
assetClass = SymbolProfileOverrides.assetClass ?? assetClass;
assetSubClass =
SymbolProfileOverrides.assetSubClass ?? assetSubClass;
if (
(SymbolProfileOverrides.countries as unknown as Prisma.JsonArray)
?.length > 0
) {
countriesCount = (
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
).length;
}
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(
async ({
_count,
activities,
name = SymbolProfileOverrides.name ?? name;
if (
(SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
0
) {
sectorsCount = (
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
).length;
}
}
return {
assetClass,
assetSubClass,
comment,
countries,
countriesCount,
currency,
dataSource,
id,
isActive,
isUsedByUsersWithSubscription,
lastMarketPrice,
marketDataItemCount,
name,
sectors,
sectorsCount,
symbol,
SymbolProfileOverrides
}) => {
let countriesCount = countries ? Object.keys(countries).length : 0;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
let sectorsCount = sectors ? Object.keys(sectors).length : 0;
if (SymbolProfileOverrides) {
assetClass = SymbolProfileOverrides.assetClass ?? assetClass;
assetSubClass =
SymbolProfileOverrides.assetSubClass ?? assetSubClass;
if (
(
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
)?.length > 0
) {
countriesCount = (
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
).length;
}
name = SymbolProfileOverrides.name ?? name;
if (
(SymbolProfileOverrides.sectors as unknown as Sector[])
?.length > 0
) {
sectorsCount = (
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
).length;
}
}
return {
assetClass,
assetSubClass,
comment,
currency,
countriesCount,
dataSource,
id,
isActive,
lastMarketPrice,
name,
symbol,
marketDataItemCount,
sectorsCount,
activitiesCount: _count.activities,
date: activities?.[0]?.date,
isUsedByUsersWithSubscription:
await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy
};
}
)
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
activitiesCount: _count.activities,
date: activities?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy
};
}
)
);
count = marketData.length;
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
}
return {
count,
marketData
};
} finally {
await extendedPrismaClient.$disconnect();
Logger.debug('Disconnect extended prisma client', 'AdminService');
count = marketData.length;
}
return {
count,
marketData
};
}
public async getMarketDataBySymbol({
@ -704,8 +694,6 @@ export class AdminService {
}
private getExtendedPrismaClient() {
Logger.debug('Connect extended prisma client', 'AdminService');
const symbolProfileExtension = Prisma.defineExtension((client) => {
return client.$extends({
result: {
@ -746,7 +734,7 @@ export class AdminService {
});
});
return new PrismaClient().$extends(symbolProfileExtension);
return this.prismaService.$extends(symbolProfileExtension);
}
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {

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

@ -7,6 +7,7 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { getSum } from '@ghostfolio/common/helper';
import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -58,7 +59,7 @@ export class PublicController {
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium';
hasDetails = user.subscription.type === SubscriptionType.Premium;
}
const [

4
apps/api/src/app/endpoints/sitemap/sitemap.service.ts

@ -120,6 +120,10 @@ export class SitemapService {
{
languageCode: 'en',
routerLink: ['2025', '11', 'black-weeks-2025']
},
{
languageCode: 'en',
routerLink: ['2026', '04', 'ghostfolio-3']
}
]
.map(({ languageCode, routerLink }) => {

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

@ -17,6 +17,7 @@ import {
HEADER_KEY_IMPERSONATION,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
PortfolioDetails,
PortfolioDividendsResponse,
@ -92,7 +93,8 @@ export class PortfolioController {
let hasError = false;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = this.request.user.subscription.type === 'Premium';
hasDetails =
this.request.user.subscription.type === SubscriptionType.Premium;
}
const filters = this.apiService.buildFiltersFromQueryParams({
@ -356,7 +358,7 @@ export class PortfolioController {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
this.request.user.subscription.type === SubscriptionType.Basic
) {
dividends = dividends.map((item) => {
return nullifyValuesInObject(item, ['investment']);
@ -484,7 +486,7 @@ export class PortfolioController {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
this.request.user.subscription.type === SubscriptionType.Basic
) {
investments = investments.map((item) => {
return nullifyValuesInObject(item, ['investment']);
@ -596,7 +598,7 @@ export class PortfolioController {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
this.request.user.subscription.type === SubscriptionType.Basic
) {
performanceInformation.chart = performanceInformation.chart.map(
(item) => {
@ -624,7 +626,7 @@ export class PortfolioController {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
this.request.user.subscription.type === SubscriptionType.Basic
) {
for (const category of report.xRay.categories) {
category.rules = null;

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

@ -32,6 +32,7 @@ import {
TAG_ID_EXCLUDE_FROM_ANALYSIS,
locale as defaultLocale
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
User as IUser,
SystemMessage,
@ -163,7 +164,7 @@ export class UserService {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === 'Basic'
subscription.type === SubscriptionType.Basic
) {
tags = [];
}
@ -452,7 +453,7 @@ export class UserService {
createdAt: user.createdAt
});
if (user.subscription?.type === 'Basic') {
if (user.subscription?.type === SubscriptionType.Basic) {
const daysSinceRegistration = differenceInDays(
new Date(),
user.createdAt
@ -494,7 +495,7 @@ export class UserService {
// Reset holdings view mode
user.settings.settings.holdingsViewMode = undefined;
} else if (user.subscription?.type === 'Premium') {
} else if (user.subscription?.type === SubscriptionType.Premium) {
if (!hasRole(user, Role.DEMO)) {
currentPermissions.push(permissions.createApiKey);
currentPermissions.push(permissions.enableDataProviderGhostfolio);

109
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json

@ -40,6 +40,7 @@
"0NE": "Stone",
"0X0": "0x0.ai",
"0X1": "0x1.tools: AI Multi-tool Plaform",
"0X63SPIKE": "Spike",
"0XBTC": "0xBitcoin",
"0XCOCO": "0xCoco",
"0XDEV": "DEVAI",
@ -773,7 +774,7 @@
"ANALY": "Analysoor",
"ANARCHISTS": "Anarchists Prime",
"ANAT": "Anatolia Token",
"ANB": "Angryb",
"ANB": "Ant BlockChain",
"ANC": "Anchor Protocol",
"ANCHOR": "AnchorSwap",
"ANCIENTKING": "Ancient Kingdom",
@ -798,6 +799,7 @@
"ANGLE": "ANGLE",
"ANGO": "Aureus Nummus Gold",
"ANGRYSLERF": "ANGRYSLERF",
"ANGRYTOKEN": "Angryb",
"ANI": "Ani Grok Companion (anicompanion.net)",
"ANIM": "Animalia",
"ANIMA": "Realm Anima",
@ -1047,6 +1049,7 @@
"ARTDRAW": "ArtDraw",
"ARTE": "Artemine",
"ARTEM": "Artem",
"ARTEMIS": "OFFICIAL ARTEMIS",
"ARTEON": "Arteon",
"ARTEQ": "artèQ",
"ARTEX": "Artex",
@ -1120,6 +1123,10 @@
"ASTA": "ASTA",
"ASTER": "Aster",
"ASTERINU": "Aster INU",
"ASTEROID": "ASTEROID",
"ASTEROIDBOT": "Asteroid Bot",
"ASTEROIDCOIN": "ASTEROID",
"ASTEROIDETH": "Asteroid",
"ASTHERUSUSDF": "Astherus USDF",
"ASTO": "Altered State Token",
"ASTON": "Aston",
@ -1564,6 +1571,7 @@
"BANANO": "Banano",
"BANC": "Babes and Nerds",
"BANCA": "BANCA",
"BANCORUSD": "USD Bancor",
"BAND": "Band Protocol",
"BANDEX": "Banana Index",
"BANDIT": "Bandit on Base",
@ -1602,11 +1610,12 @@
"BART": "BarterTrade",
"BARTKRC": "BART Token",
"BARY": "Bary",
"BAS": "BNB Attestation Service",
"BASEAI": "BaseAI",
"BASEBEAR": "BBQ",
"BASECAT": "BASE CAT",
"BASECOIN": "BASECOIN",
"BASED": "Based Money",
"BASED": "Based Token",
"BASEDAI": "BasedAI",
"BASEDALF": "Based Alf",
"BASEDB": "Based Bonk",
@ -1614,12 +1623,13 @@
"BASEDCOPE": "COPE",
"BASEDFINANCE": "Based",
"BASEDHOPPY": "Based Hoppy (basedhoppy.vip)",
"BASEDMONEY": "Based Money",
"BASEDMONEYV1": "Based Money v1",
"BASEDP": "Based Pepe",
"BASEDR": "Based Rabbit",
"BASEDS": "BasedSwap",
"BASEDSB": "Based Street Bets",
"BASEDTURBO": "Based Turbo",
"BASEDV1": "Based Money v1",
"BASEHEROES": "Baseheroes",
"BASEPROTOCOL": "Base Protocol",
"BASESWAPX": "BaseX",
@ -1637,6 +1647,7 @@
"BASISSHAREV2": "Basis Share",
"BASK": "BasketDAO",
"BAST": "Bast",
"BASTEROID": "BABY ASTEROID",
"BASTET": "Bastet Goddess",
"BAT": "Basic Attention Token",
"BATCH": "BATCH Token",
@ -2584,7 +2595,7 @@
"BOZO": "BOZO",
"BOZOH": "bozo Hybrid",
"BOZY": "Book of Crazy",
"BP": "BunnyPark",
"BP": "Backpack",
"BPAD": "BlokPad",
"BPADA": "Binance-Peg Cardano (Binance Bridge)",
"BPAVAX": "Binance-Peg Avalanche (Binance Bridge)",
@ -2650,6 +2661,7 @@
"BREAD": "Breadchain Cooperative",
"BREE": "CBDAO",
"BREED": "BreederDAO",
"BRENT": "Brent Crude",
"BREPE": "BREPE",
"BRETARDIO": "Bretardio",
"BRETT": "Brett Base",
@ -2965,6 +2977,7 @@
"BULLA": "BULLA",
"BULLBEAR": "BullBear AI",
"BULLC": "BuySell",
"BULLDOG": "BullDog Coin",
"BULLF": "BULL FINANCE",
"BULLGOD": "Bull God",
"BULLI": "Bullish On Ethereum",
@ -2997,6 +3010,7 @@
"BUNNY": "Pancake Bunny",
"BUNNYINU": "Bunny Inu",
"BUNNYM": "BUNNY MEV BOT",
"BUNNYP": "BunnyPark",
"BUNNYROCKET": "BunnyRocket",
"BURG": "Burger",
"BURGER": "Burger Swap",
@ -3207,7 +3221,8 @@
"CARTIER": "Cartier",
"CARV": "CARV",
"CAS": "Cashaa",
"CASH": "CashCoin",
"CASH": "CASH",
"CASHCOIN": "CashCoin",
"CASHIO": "Cashio Dollar",
"CASHLY": "Cashly",
"CASHT": "Cash Tech",
@ -3232,7 +3247,7 @@
"CATCEO": "CATCEO",
"CATCH": "SpaceCatch",
"CATCO": "CatCoin",
"CATCOIN": "CatCoin",
"CATCOINCASH": "CatCoin",
"CATCOINETH": "Catcoin",
"CATCOINIO": "Catcoin",
"CATCOINOFSOL": "Cat Coin",
@ -3488,7 +3503,7 @@
"CHARTIQ": "ChartIQ",
"CHAS": "Chasm",
"CHASH": "CleverHash",
"CHAT": "Solchat",
"CHAT": "OpenChat",
"CHATAI": "ChatAI Token",
"CHATGPT": "AI Dragon",
"CHATOSHI": "chAtoshI",
@ -3826,7 +3841,8 @@
"COFIX": "CoFIX",
"COFOUNDIT": "Cofound.it",
"COG": "Cognitio",
"COGE": "Cogecoin",
"COGE": "Copper Doge",
"COGECOIN": "Cogecoin",
"COGEN": "Cogenero",
"COGI": "COGI",
"COGS": "Cogmento",
@ -4350,6 +4366,7 @@
"CWR": "Cowrium",
"CWS": "Crowns",
"CWT": "CrossWallet",
"CWU": "Commonwealth",
"CWV": "CryptoWave",
"CWX": "Crypto-X",
"CWXT": "CryptoWorldXToken",
@ -4673,6 +4690,7 @@
"DEFIK": "DeFi Kingdoms JADE",
"DEFIL": "DeFIL",
"DEFILAB": "Defi",
"DEFINITIVE": "Edge",
"DEFISCALE": "DeFiScale",
"DEFISSI": "DEFI.ssi",
"DEFIT": "Digital Fitness",
@ -5343,7 +5361,8 @@
"DTV": "DraperTV",
"DTX": "DataBroker DAO",
"DUA": "Brillion",
"DUAL": "Dual Finance",
"DUAL": "DUAL",
"DUALDAOTOKEN": "Dual Finance",
"DUB": "DubCoin",
"DUBAICAT": "Dubai Cat",
"DUBBZ": "Dubbz",
@ -5487,6 +5506,7 @@
"EARTH": "Earth Token",
"EARTHCOIN": "EarthCoin",
"EARTHM": "Earthmeta",
"EARTHY": "Little Earth Buddy",
"EASY": "EASY",
"EASYF": "EasyFeedback",
"EASYMINE": "EasyMine",
@ -5566,7 +5586,7 @@
"EDEXA": "edeXa Security Token",
"EDFI": "EdFi",
"EDG": "Edgeless",
"EDGE": "Definitive",
"EDGE": "edgeX",
"EDGEACTIVITY": "EDGE Activity Token",
"EDGEAI": "EdgeAI",
"EDGEN": "LayerEdge",
@ -5650,6 +5670,7 @@
"EIGENP": "Eigenpie",
"EIM": "Expert Infra",
"EIQT": "IQ Prediction",
"EITHER": "Eitherway",
"EJAC": "EJA Coin",
"EJS": "Enjinstarter",
"EKG": "Ekon Gold",
@ -5765,6 +5786,7 @@
"EML": "EML Protocol",
"EMMM": "emmm",
"EMN.CUR": "Eastman Chemical",
"EMOGINETWORK": "EMOGI Network",
"EMOJI": "MOMOJI",
"EMON": "Ethermon",
"EMONEYEUR": "e-Money EUR",
@ -6063,10 +6085,11 @@
"EUSX": "eUSX",
"EUT": "EarnUp Token",
"EUTBL": "Spiko EU T-Bills Money Market Fund",
"EV": "EVAI",
"EV": "Everything",
"EVA": "Evadore",
"EVAA": "EVAA Protocol",
"EVAI": "EVA Intelligence",
"EVAIIO": "EVAI",
"EVAL": "Chromia's EVAL by Virtuals",
"EVAN": "Evanesco Network",
"EVAULT": "EthereumVault",
@ -7101,6 +7124,7 @@
"GENIESWAP": "GenieSwap",
"GENIESWAPV1": "GenieSwap v1",
"GENIFYART": "Genify ART",
"GENIUS": "Genius",
"GENIX": "Genix",
"GENO": "GenomeFi",
"GENOME": "GenomesDao",
@ -7740,6 +7764,7 @@
"HACHIK": "Hachiko",
"HACHIKO": "Hachiko Inu Token",
"HACHIKOINU": "Hachiko Inu",
"HACHIKOTOKEN": "Hachiko",
"HACHIONB": "Hachi On Base",
"HACHITOKEN": "Hachi",
"HACK": "HACK",
@ -7747,6 +7772,7 @@
"HAEDAL": "Haedal Protocol",
"HAGGIS": "New Born Haggis Pygmy Hippo",
"HAHA": "Hasaki",
"HAHAYESRIZO": "Haha Yes Hedgehog",
"HAI": "Hacken Token",
"HAIO": "HAiO",
"HAIR": " HairDAO",
@ -7776,6 +7802,7 @@
"HANACOIN": "Hanacoin",
"HANAETH": "Hana",
"HANAETHCTO": "HANA",
"HANC": "OddHanc",
"HAND": "ShowHand",
"HANDY": "Handy",
"HANK": "Hank",
@ -7806,6 +7833,7 @@
"HASBIK": "Hasbulla",
"HASH": "Provenance Blockchain",
"HASHAI": "HashAI",
"HASHCOIN": "HASH Coin",
"HASHNET": "HashNet BitEco",
"HASHT": "HASH Token",
"HASUI": "Haedal",
@ -8411,6 +8439,7 @@
"IG": "IG Token ",
"IGCH": "IG-Crypto Holding",
"IGG": "IG Gold",
"IGGT": "The Invincible Game Token",
"IGI": "Igi",
"IGNIS": "Ignis",
"IGT": "Infinitar",
@ -8526,7 +8555,8 @@
"INNBC": "Innovative Bioresearch Coin",
"INNOU": "Innou",
"INNOVAMINEX": "InnovaMinex",
"INO": "Ino Coin",
"INO": "InoAi",
"INOCOIN": "Ino Coin",
"INOVAI": "INOVAI",
"INP": "Ionic Pocket Token",
"INRT": "INRToken",
@ -8709,6 +8739,7 @@
"IVY": "IvyKoin",
"IVZ": "InvisibleCoin",
"IW": "iWallet",
"IWC": "IWC",
"IWFT": "İstanbul Wild Cats",
"IWMON": "iShares Russell 2000 ETF (Ondo Tokenized)",
"IWT": "IwToken",
@ -9382,6 +9413,7 @@
"KRD": "Krypton DAO",
"KREDS": "KREDS",
"KREST": "krest Network",
"KRGN": "Kerrigan Network",
"KRIDA": "KridaFans",
"KRIPTO": "Kripto",
"KRL": "Kryll",
@ -9897,7 +9929,7 @@
"LOKA": "League of Kingdoms",
"LOKR": "Polkalokr",
"LOKY": "Loky by Virtuals",
"LOL": "EMOGI Network",
"LOL": "LOL",
"LOLA": "Lola",
"LOLATHECAT": "Lola",
"LOLC": "LOL Coin",
@ -9907,9 +9939,11 @@
"LOLO": "Lolo",
"LOLONBSC": "LOL",
"LON": "Tokenlon",
"LONG": "Longdrink Finance",
"LONG": "LONG",
"LONGDRINK": "Longdrink Finance",
"LONGEVITY": "longevity",
"LONGFU": "LONGFU",
"LONGFUN": "Long",
"LONGM": "Long Mao",
"LONGSHINE": "LongShine",
"LOOBY": "Looby by Stephen Bliss",
@ -10360,6 +10394,7 @@
"MAXL": "Maxi protocol",
"MAXR": "Max Revive",
"MAXX": "MAXX Finance",
"MAXXING": "Maxxing",
"MAY": "Mayflower",
"MAYA": "Maya",
"MAYACOIN": "MayaCoin",
@ -10667,6 +10702,8 @@
"MEXC": "MEXC Token",
"MEXP": "MOJI Experience Points",
"MEY": "Mey Network",
"MEZO": "MEZO",
"MEZOUSD": "Mezo USD",
"MEZZ": "MEZZ Token",
"MF": "Moonwalk Fitness",
"MF1": "Meta Finance",
@ -10713,7 +10750,8 @@
"MHT": "Mouse Haunt",
"MHUNT": "MetaShooter",
"MI": "XiaoMiCoin",
"MIA": "MiamiCoin",
"MIA": "MIA",
"MIAMICOIN": "MiamiCoin",
"MIAO": "MIAOCoin",
"MIB": "Mobile Integrated Blockchain",
"MIBO": "miBoodle",
@ -11034,7 +11072,7 @@
"MOLO": "MOLO CHAIN",
"MOLT": "Moltbook",
"MOLTID": "MoltID",
"MOM": "Mother of Memes",
"MOM": "MOM",
"MOMA": "Mochi Market",
"MOMIJI": "MAGA Momiji",
"MOMO": "Momo",
@ -11120,6 +11158,7 @@
"MOONEY": "Moon DAO",
"MOONI": "MOON INU",
"MOONION": "Moonions",
"MOONKIN": "MOONKIN",
"MOONKIZE": "MoonKize",
"MOONLIGHT": "Moonlight Token",
"MOONPIG": "Moonpig",
@ -11156,6 +11195,7 @@
"MOTG": "MetaOctagon",
"MOTH": "MOTH",
"MOTHER": "Mother Iggy",
"MOTHEROFMEMES": "Mother of Memes",
"MOTI": "Motion",
"MOTION": "motion",
"MOTIONCOIN": "Motion",
@ -12140,7 +12180,7 @@
"OEX": "OEX",
"OF": "OFCOIN",
"OFBC": "OneFinBank Coin",
"OFC": "$OFC Coin",
"OFC": "OneFootball Club",
"OFCR": "CryptoPolice",
"OFE": "Ofero",
"OFF": "BlastOff",
@ -12278,6 +12318,7 @@
"ONLINE": "Onlinebase",
"ONLY": "OnlyCam",
"ONLYCUMIES": "OnlyCumies",
"ONLYFANSCOINS": "$OFC Coin",
"ONNO": "Onno Vault",
"ONOMY": "Onomy Protocol",
"ONOT": "ONO",
@ -12506,6 +12547,7 @@
"OXY2": "Cryptoxygen",
"OXYC": "Oxycoin",
"OYS": "Oyster Platform",
"OYSTERPEARL": "Oyster Pearl",
"OZG": "Ozagold",
"OZK": "OrdiZK",
"OZMPC": "Ozempic",
@ -13021,7 +13063,6 @@
"PINMO": "Pinmo",
"PINO": "Pinocchu",
"PINS": "PINs Network Token",
"PINU": "Piccolo Inu",
"PINU100X": "Pi INU 100x",
"PIO": "Pioneershares",
"PIP": "Pip",
@ -13412,7 +13453,7 @@
"PRISMA": "Prisma Finance",
"PRIVIX": "Privix",
"PRIX": "Privatix",
"PRL": "Oyster Pearl",
"PRL": "Perle",
"PRM": "PrismChain",
"PRMX": "PREMA",
"PRNT": "Prime Numbers",
@ -13442,6 +13483,7 @@
"PROTEO": "Proteo DeFi",
"PROTO": "Protocon",
"PROTOCOLZ": "Protocol Zero",
"PROTOKEN": "Pro Token",
"PROTON": "Proton",
"PROUD": "PROUD Money",
"PROVE": "Succinct",
@ -13540,6 +13582,7 @@
"PUMPB": "Pump",
"PUMPBTC": "pumpBTC",
"PUMPBTCXYZ": "PumpBTC",
"PUMPCADE": "PUMPCADE",
"PUMPFUNBAN": "Pump Fun Ban",
"PUMPIT": "BOGDANOFF",
"PUMPTRUMP": "PUMP TRUMP",
@ -14058,6 +14101,7 @@
"RETSA": "Retsa Coin",
"REU": "REUCOIN",
"REUNI": "Reunit Wallet",
"REUR": "Royal Euro",
"REUSDC": "Relend USDC",
"REV": "Revain",
"REV3L": "REV3AL",
@ -14159,6 +14203,7 @@
"RIPTO": "RiptoBuX",
"RIS": "Riser",
"RISE": "EverRise",
"RISECOIN": "Rise coin",
"RISEP": "Rise Protocol",
"RISEVISION": "Rise",
"RISITA": "Risitas",
@ -14166,6 +14211,7 @@
"RITE": "ritestream",
"RITO": "Ritocoin",
"RITZ": "Ritz.Game",
"RIV": "RIV Coin",
"RIVER": "River",
"RIVERPTS": "River Point Reward Token",
"RIVUS": "RivusDAO",
@ -14602,7 +14648,7 @@
"SBCH": "Smart Bitcoin Cash",
"SBE": "Sombe",
"SBEFE": "BEFE",
"SBET": "SBET",
"SBET": "Sports Bet",
"SBF": "SBF In Jail",
"SBGO": "Bingo Share",
"SBIO": "Vector Space Biosciences, Inc.",
@ -15410,6 +15456,7 @@
"SOLCAT": "CatSolHat",
"SOLCATMEME": "SOLCAT",
"SOLCEX": "SolCex",
"SOLCHAT": "Solchat",
"SOLCHICKSSHARDS": "SolChicks Shards",
"SOLE": "SoleCoin",
"SOLER": "Solerium",
@ -15420,6 +15467,7 @@
"SOLFUN": "SolFun",
"SOLGOAT": "SOLGOAT",
"SOLGUN": "Solgun",
"SOLIB": "Solitaire Blossom",
"SOLIC": "Solice",
"SOLID": "Solidified",
"SOLIDSEX": "SOLIDsex: Tokenized veSOLID",
@ -15583,7 +15631,11 @@
"SPIDER": "Spider Man",
"SPIDERMAN": "SPIDERMAN BITCOIN",
"SPIDEY": "Spidey",
"SPIK": "Spike",
"SPIKE": "Spiking",
"SPIKE1984": "Spike 1984",
"SPIKECOIN": "SPIKE",
"SPIKEFURIE": "SPIKE",
"SPILLWAYS": "SpillWays",
"SPIN": "SPIN Protocol",
"SPINT": "Spintria",
@ -15591,6 +15643,7 @@
"SPITT": "Hawk Ttuuaahh",
"SPIZ": "SPACE-iZ",
"SPK": "Spark",
"SPKI": "SPIKE INU",
"SPKL": "SpokLottery",
"SPKTR": "Ghost Coin",
"SPKY": "GhostyCash",
@ -15612,6 +15665,7 @@
"SPOOL": "Spool DAO Token",
"SPORE": "Spore",
"SPORT": "SportsCoin",
"SPORTBET": "SBET",
"SPORTFUN": "Sport.fun",
"SPORTS": "ZenSports",
"SPORTSFIX": "SportsFix",
@ -16571,6 +16625,7 @@
"TKX": "Tokenize Xchange",
"TKY": "THEKEY Token",
"TLC": "Trillioner",
"TLF": "Tradeleaf",
"TLM": "Alien Worlds",
"TLN": "Trustlines Network",
"TLOS": "Telos",
@ -17075,6 +17130,7 @@
"UBQ": "Ubiq",
"UBT": "UniBright",
"UBTC": "UnitedBitcoin",
"UBU": "UBU",
"UBX": "UBIX Network",
"UBXN": "UpBots Token",
"UBXS": "UBXS",
@ -17169,6 +17225,7 @@
"UNBNK": "Unbanked",
"UNBREAKABLE": "UnbreakableCoin",
"UNC": "UnCoin",
"UNCEROID": "unc asteroid",
"UNCL": "UNCL",
"UNCN": "Unseen",
"UNCOMMONGOODS": "UNCOMMON•GOODS",
@ -17241,6 +17298,7 @@
"UNRC": "UniversalRoyalCoin",
"UNS": "UNS TOKEN",
"UNSHETH": "unshETH Ether",
"UNT": "Uni Token",
"UNW": "UniWorld",
"UOP": "Utopia Genesis Foundation",
"UOS": "UOS",
@ -17296,7 +17354,7 @@
"USDAI": "USDai",
"USDAP": "Bond Appetite USD",
"USDAVALON": "USDa",
"USDB": "USD Bancor",
"USDB": "Blynex USD",
"USDBC": "Bridged USDC",
"USDBLAST": "USDB Blast",
"USDC": "USD Coin",
@ -17412,13 +17470,14 @@
"UTNP": "Universa",
"UTON": "uTON",
"UTOPIA": "Utopia",
"UTOPIAUSD": "Utopia USD",
"UTT": "uTrade",
"UTU": "UTU Protocol",
"UTX": "UTIX",
"UTYA": "Utya",
"UTYAB": "Utya Black",
"UUC": "USA Unity Coin",
"UUSD": "Utopia USD",
"UUSD": "Unity USD",
"UUU": "U Network",
"UVT": "UvToken",
"UW3S": "Utility Web3Shot",
@ -17621,7 +17680,8 @@
"VIKKY": "VikkyToken",
"VILADY": "Vitalik Milady",
"VIM": "VicMove",
"VIN": "VinChain",
"VIN": "VulgarTycoon",
"VINCHAIN": "VinChain",
"VINCI": "VINCI",
"VINE": "Vine Coin",
"VINU": "Vita Inu",
@ -17716,6 +17776,7 @@
"VOL": "Volume Network",
"VOLBOOST": "VolBoost",
"VOLLAR": "Vollar",
"VOLM": "VOLM",
"VOLR": "Volare Network",
"VOLT": "Volt Inu",
"VOLTA": "Volta Club",
@ -18488,6 +18549,7 @@
"XCG": "Xchange",
"XCH": "Chia",
"XCHAT": "XChat",
"XCHATSOL": "XChat",
"XCHF": "CryptoFranc",
"XCHNG": "Chainge Finance",
"XCI": "Cannabis Industry Coin",
@ -18543,6 +18605,7 @@
"XENOVERSE": "Xenoverse",
"XEP": "Electra Protocol",
"XERA": "XERA",
"XERO": "XERO",
"XERS": "X Project",
"XES": "Proxeus",
"XET": "Xfinite Entertainment Token",

4
apps/api/src/middlewares/html-template.middleware.ts

@ -83,6 +83,10 @@ const locales = {
'/en/blog/2025/11/black-weeks-2025': {
featureGraphicPath: 'assets/images/blog/black-weeks-2025.jpg',
title: `Black Weeks 2025 - ${title}`
},
'/en/blog/2026/04/ghostfolio-3': {
featureGraphicPath: 'assets/images/blog/ghostfolio-3.jpg',
title: `Announcing Ghostfolio 3.0 - ${title}`
}
};

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

@ -12,6 +12,7 @@ import {
PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
@ -227,7 +228,7 @@ export class DataProviderService implements OnModuleInit {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user.subscription.type === 'Basic'
user.subscription.type === SubscriptionType.Basic
) {
const dataProvider = this.getDataProvider(DataSource[dataSource]);
@ -591,7 +592,7 @@ export class DataProviderService implements OnModuleInit {
} else if (
dataProvider.getDataProviderInfo().isPremium &&
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user?.subscription.type === 'Basic'
user?.subscription.type === SubscriptionType.Basic
) {
// Skip symbols of Premium data providers for users without subscription
return false;
@ -780,7 +781,7 @@ export class DataProviderService implements OnModuleInit {
})
.map((lookupItem) => {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (user.subscription.type === 'Premium') {
if (user.subscription.type === SubscriptionType.Premium) {
lookupItem.dataProviderInfo.isPremium = false;
}

6
apps/api/src/services/prisma/prisma.service.ts

@ -6,6 +6,7 @@ import {
OnModuleInit
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { Prisma, PrismaClient } from '@prisma/client';
@Injectable()
@ -14,6 +15,10 @@ export class PrismaService
implements OnModuleInit, OnModuleDestroy
{
public constructor(configService: ConfigService) {
const adapter = new PrismaPg({
connectionString: configService.get<string>('DATABASE_URL')
});
let customLogLevels: LogLevel[];
try {
@ -28,6 +33,7 @@ export class PrismaService
: [];
super({
adapter,
log,
errorFormat: 'colorless'
});

2
apps/client/src/app/app.component.ts

@ -336,7 +336,9 @@ export class GfAppComponent implements OnInit {
if (isDarkTheme) {
this.document.body.classList.add('theme-dark');
this.document.body.classList.remove('theme-light');
} else {
this.document.body.classList.add('theme-light');
this.document.body.classList.remove('theme-dark');
}

7
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -8,7 +8,10 @@ import {
PROPERTY_SYSTEM_MESSAGE,
ghostfolioPrefix
} from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import {
ConfirmationDialogType,
SubscriptionType
} from '@ghostfolio/common/enums';
import { getDateFnsLocale } from '@ghostfolio/common/helper';
import {
Coupon,
@ -255,7 +258,7 @@ export class GfAdminOverviewComponent implements OnInit {
this.systemMessage ??
({
message: '⚒️ Scheduled maintenance in progress...',
targetGroups: ['Basic', 'Premium']
targetGroups: [SubscriptionType.Basic, SubscriptionType.Premium]
} as SystemMessage)
)
);

68
apps/client/src/app/components/header/header.component.html

@ -2,7 +2,7 @@
@if (user) {
<div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
<a
class="align-items-center justify-content-start rounded-0"
class="align-items-center h-100 justify-content-start rounded-0"
mat-button
[ngClass]="{ 'w-100': hasTabs }"
[routerLink]="['/']"
@ -15,9 +15,9 @@
<ul class="align-items-center d-flex list-inline m-0 px-2">
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold':
currentRoute === internalRoutes.home.path ||
@ -32,9 +32,9 @@
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.portfolio.path,
'text-decoration-underline':
@ -46,9 +46,9 @@
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.accounts.path,
'text-decoration-underline':
@ -61,9 +61,9 @@
@if (hasPermissionToAccessAdminControl) {
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold':
currentRoute === internalRoutes.adminControl.path,
@ -77,9 +77,9 @@
}
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === routeResources,
'text-decoration-underline': currentRoute === routeResources
@ -93,8 +93,8 @@
) {
<li class="list-inline-item">
<a
class="d-none d-sm-block"
mat-flat-button
class="d-none d-sm-block rounded"
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === routePricing
@ -112,9 +112,9 @@
}
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === routeAbout,
'text-decoration-underline': currentRoute === routeAbout
@ -124,15 +124,15 @@
>
</li>
@if (hasPermissionToAccessAssistant) {
<li class="list-inline-item">
<li class="d-inline-flex list-inline-item">
<button
#assistantTrigger="matMenuTrigger"
class="h-100 no-min-width px-2"
mat-button
matBadge="✓"
matBadge="&NoBreak;"
matBadgeSize="small"
[mat-menu-trigger-for]="assistantMenu"
matButton
[matBadgeHidden]="!hasFilters || !hasPermissionToChangeFilters"
[matMenuTriggerFor]="assistantMenu"
[matMenuTriggerRestoreFocus]="false"
(menuOpened)="onOpenAssistant()"
>
@ -164,7 +164,7 @@
<li class="list-inline-item">
<button
class="no-min-width px-1"
mat-flat-button
mat-button
[matMenuTriggerFor]="accountMenu"
(menuClosed)="onMenuClosed()"
(menuOpened)="onMenuOpened()"
@ -334,7 +334,7 @@
@if (user === null) {
<div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
<a
class="align-items-center justify-content-start rounded-0"
class="align-items-center h-100 justify-content-start rounded-0"
mat-button
[ngClass]="{ 'w-100': hasTabs }"
[routerLink]="['/']"
@ -350,9 +350,9 @@
<ul class="align-items-center d-flex list-inline m-0 px-2">
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === routeFeatures,
'text-decoration-underline': currentRoute === routeFeatures
@ -363,9 +363,9 @@
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === routeAbout,
'text-decoration-underline': currentRoute === routeAbout
@ -377,8 +377,8 @@
@if (hasPermissionForSubscription) {
<li class="list-inline-item">
<a
class="d-sm-block"
mat-flat-button
class="d-sm-block rounded"
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === routePricing
@ -397,9 +397,9 @@
@if (hasPermissionToAccessFearAndGreedIndex) {
<li class="list-inline-item">
<a
class="d-none d-sm-block"
class="d-none d-sm-block rounded"
i18n
mat-flat-button
mat-button
[ngClass]="{
'font-weight-bold': currentRoute === routeMarkets,
'text-decoration-underline': currentRoute === routeMarkets
@ -413,19 +413,23 @@
<a
class="d-none d-sm-block no-min-width p-1"
href="https://github.com/ghostfolio/ghostfolio"
mat-flat-button
mat-button
><ion-icon name="logo-github"
/></a>
</li>
<li class="list-inline-item">
<button class="d-sm-block" mat-flat-button (click)="openLoginDialog()">
<button
class="d-sm-block rounded"
mat-button
(click)="openLoginDialog()"
>
<ng-container i18n>Sign in</ng-container>
</button>
</li>
@if (currentRoute !== 'register' && hasPermissionToCreateUser) {
<li class="list-inline-item ml-1">
<a
class="d-none d-sm-block"
class="d-none d-sm-block px-3 rounded"
color="primary"
i18n
mat-flat-button

13
apps/client/src/app/components/header/header.component.scss

@ -24,7 +24,7 @@
}
.mdc-button {
height: 100%;
color: var(--mdc-text-button-label-text-color);
&:not(.mat-primary) {
background-color: transparent;
@ -32,17 +32,6 @@
text-underline-offset: 0.25rem;
}
&.mat-badge {
::ng-deep {
.mat-badge-content {
--mat-badge-small-size-container-overlap-offset: -0.9rem;
--mat-badge-small-size-text-size: 0;
transform: scale(0.45);
}
}
}
ion-icon {
font-size: 1.5rem;

21
apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.component.ts

@ -0,0 +1,21 @@
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-ghostfolio-3-page',
templateUrl: './ghostfolio-3-page.html'
})
export class Ghostfolio3PageComponent {
public pricingUrl = `https://ghostfol.io/${document.documentElement.lang}/${publicRoutes.pricing.path}`;
public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkAboutChangelog =
publicRoutes.about.subRoutes.changelog.routerLink;
public routerLinkBlog = publicRoutes.blog.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkMarkets = publicRoutes.markets.routerLink;
}

281
apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html

@ -0,0 +1,281 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Ghostfolio 3.0</h1>
<div class="mb-3 text-muted"><small>2026-04-23</small></div>
<img
alt="Ghostfolio 3.0 Teaser"
class="border rounded w-100"
src="../assets/images/blog/ghostfolio-3.jpg"
title="Announcing Ghostfolio 3.0"
/>
</div>
<section class="mb-4">
<p>
Since the
<a href="../en/blog/2023/09/ghostfolio-2">last major version</a>
of <a [routerLink]="routerLinkAbout">Ghostfolio</a>, we have shipped
over 250 releases. The project now counts 275+ contributors from
around the globe and has surpassed 2’300’000 pulls on Docker Hub.
These milestones reflect steady adoption and our focus on
simplifying investment tracking while prioritizing user privacy.
</p>
<p>
Today’s release marks the next major version in our Open Source
Software (OSS) journey.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Introducing Ghostfolio 3.0</h2>
<p>
Ghostfolio 3.0 is the evolution of our
<a [routerLink]="routerLinkFeatures"
>open source wealth management software</a
>, with meaningful improvements for both users and developers. We
have refreshed the user interface, expanded analytics, improved
stability, added more languages, and updated the technology stack to
support these changes. Here is a closer look at a selection of what
you can expect from this
<a [routerLink]="routerLinkAboutChangelog">release</a>, alongside
many smaller additions and enhancements.
</p>
<h3 class="h5">Refreshed User Interface</h3>
<p>
Ghostfolio 3.0 comes with a refreshed user interface that modernizes
the visual appearance of the application. The updated design is
cleaner, with refined components and improved consistency across the
platform.
</p>
<h3 class="h5">Comprehensive Analytics</h3>
<p>
This release provides a broader set of tools to help you understand
your portfolio. Ghostfolio X-ray uses static analysis to highlight
potential issues and risks, with rules that can now be customized to
match your investment strategy.
</p>
<h3 class="h5">Extended Multilanguage Support</h3>
<p>
Largely driven by contributions from the community, Ghostfolio now
supports more languages than ever. The application is available in a
growing number of languages, making it accessible to a broader
audience of investors around the world.
</p>
<h3 class="h5">Reliable Stability</h3>
<p>
A wealth management platform needs to be reliable. With Ghostfolio
3.0, we have further strengthened the robustness of our
architecture, so you can rely on Ghostfolio across different
<a [routerLink]="routerLinkMarkets">market conditions</a>.
</p>
<h3 class="h5">Empowering Self-Hosting</h3>
<p>
This release simplifies and extends the self-hosting experience. A
major addition is that self-hosters can now fully benefit from
<a target="_blank" [href]="pricingUrl">Ghostfolio Premium</a> to
make use of a professional data provider. This gives you full
control over your infrastructure while still having access to
high-quality market data for portfolio analytics.
</p>
<h3 class="h5">Updated Technology Stack</h3>
<p>
Under the hood, Ghostfolio 3.0 has been upgraded to
<a href="https://angular.io" target="_blank">Angular 21</a>,
<a href="https://nestjs.com" target="_blank">Nest.js 11</a>,
<a href="https://www.prisma.io" target="_blank">Prisma 7</a>, and
<a href="https://nx.dev" target="_blank">Nx 22</a>. Keeping the
technology stack up to date helps us provide a solid foundation for
users and developers.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Thriving Ghostfolio Community</h2>
<p>
Ghostfolio is built in public, and its community plays a central
role in shaping the open source project. Here are some highlights of
the community growth:
</p>
<ul>
<li>
Ghostfolio has accumulated <strong>8’000+ stars</strong> on
<a
href="https://github.com/ghostfolio/ghostfolio"
target="_blank"
title="Find Ghostfolio on GitHub"
>GitHub</a
>, reflecting the growing interest and trust in the project.
</li>
<li>
The
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
title="Join the Ghostfolio Slack community"
>Slack</a
>
community has grown to over <strong>1’250 members</strong>, where
investors exchange ideas and help each other.
</li>
<li>
Over
<strong>700 investors and personal finance enthusiasts</strong>
follow Ghostfolio on
<a href="https://x.com/ghostfolio_" target="_blank">X</a>
(formerly Twitter) for updates and discussions.
</li>
</ul>
<p>
There is much more to come. If you are not part of the community
yet, we would love to have you on board.
</p>
<p>
<strong>Join our Slack community</strong>: Connect with fellow
investors, share insights, and stay updated by joining our
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
title="Join the Ghostfolio Slack community"
>Slack</a
>
community.
</p>
<p>
<strong>Follow us on X</strong>: For release updates and market
insights, follow
<a href="https://x.com/ghostfolio_" target="_blank"
>Ghostfolio on X</a
>
to stay informed.
</p>
<p>
<strong>Give us a Star</strong>: If Ghostfolio has been useful to
you, please consider giving us a star on
<a
href="https://github.com/ghostfolio/ghostfolio"
target="_blank"
title="Find Ghostfolio on GitHub"
>GitHub</a
>. Your support helps us continue improving Ghostfolio.
</p>
<p>
<strong>Become a contributor</strong>: Interested in getting
involved? We welcome contributions from developers who are willing
to dive into open source and personal finance topics.
<a href="https://github.com/ghostfolio/ghostfolio" target="_blank"
>Join our developer community</a
>
and help shape the future of Ghostfolio.
</p>
</section>
<section>
<p>
Ghostfolio 3.0 is the result of countless contributions, feedback,
and shared passion for open source and personal finance. Whether you
have been with us from the start or are just discovering the
project, thank you for being part of this community.
</p>
<p>Thomas from Ghostfolio</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Angular</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Announcement</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Collaboration</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Contribution</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Evolution</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio 3.0</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio Premium</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Internationalization</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Nest.js</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Nx</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Platform</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Prisma</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Privacy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Release</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Self-Hosting</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Stack</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Technology</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
</ul>
</section>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a i18n [routerLink]="routerLinkBlog">Blog</a>
</li>
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Announcing Ghostfolio 3.0
</li>
</ol>
</nav>
</article>
</div>
</div>
</div>

26
apps/client/src/app/pages/blog/blog-page.html

@ -8,6 +8,32 @@
finance</small
>
</h1>
<mat-card appearance="outlined" class="mb-3">
<mat-card-content class="p-0">
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex overflow-hidden p-3 w-100"
href="../en/blog/2026/04/ghostfolio-3"
>
<div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">
Announcing Ghostfolio 3.0
</div>
<div class="d-flex text-muted">2026-04-03</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
/>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
@if (hasPermissionForSubscription) {
<mat-card appearance="outlined" class="mb-3">
<mat-card-content class="p-0">

9
apps/client/src/app/pages/blog/blog-page.routes.ts

@ -218,5 +218,14 @@ export const routes: Routes = [
(c) => c.BlackWeeks2025PageComponent
),
title: 'Black Weeks 2025'
},
{
canActivate: [AuthGuard],
path: '2026/04/ghostfolio-3',
loadComponent: () =>
import('./2026/04/ghostfolio-3/ghostfolio-3-page.component').then(
(c) => c.Ghostfolio3PageComponent
),
title: 'Ghostfolio 3.0'
}
];

124
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -75,70 +75,68 @@
</div>
}
@if (user?.settings?.isExperimentalFeatures) {
<div class="mb-5 row">
<div class="col-lg-4 mb-3 mb-lg-0">
<mat-card appearance="outlined">
<mat-card-content>
<gf-value
i18n
size="large"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[precision]="precision"
[unit]="user?.settings?.baseCurrency"
[value]="
isLoadingInvestmentChart
? undefined
: performance?.currentValueInBaseCurrency
"
>Total amount</gf-value
>
</mat-card-content>
</mat-card>
</div>
<div class="col-lg-4 mb-3 mb-lg-0">
<mat-card appearance="outlined">
<mat-card-content>
<gf-value
i18n
size="large"
[colorizeSign]="true"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[precision]="precision"
[unit]="user?.settings?.baseCurrency"
[value]="
isLoadingInvestmentChart
? undefined
: performance?.netPerformanceWithCurrencyEffect
"
>Change with currency effect</gf-value
>
</mat-card-content>
</mat-card>
</div>
<div class="col-lg-4">
<mat-card appearance="outlined">
<mat-card-content>
<gf-value
i18n
size="large"
[colorizeSign]="true"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="
isLoadingInvestmentChart
? undefined
: performance?.netPerformancePercentageWithCurrencyEffect
"
>Performance with currency effect</gf-value
>
</mat-card-content>
</mat-card>
</div>
<div class="mb-5 row">
<div class="col-lg-4 mb-3 mb-lg-0">
<mat-card appearance="outlined">
<mat-card-content>
<gf-value
i18n
size="large"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[precision]="precision"
[unit]="user?.settings?.baseCurrency"
[value]="
isLoadingInvestmentChart
? undefined
: performance?.currentValueInBaseCurrency
"
>Total amount</gf-value
>
</mat-card-content>
</mat-card>
</div>
}
<div class="col-lg-4 mb-3 mb-lg-0">
<mat-card appearance="outlined">
<mat-card-content>
<gf-value
i18n
size="large"
[colorizeSign]="true"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[precision]="precision"
[unit]="user?.settings?.baseCurrency"
[value]="
isLoadingInvestmentChart
? undefined
: performance?.netPerformanceWithCurrencyEffect
"
>Change with currency effect</gf-value
>
</mat-card-content>
</mat-card>
</div>
<div class="col-lg-4">
<mat-card appearance="outlined">
<mat-card-content>
<gf-value
i18n
size="large"
[colorizeSign]="true"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="
isLoadingInvestmentChart
? undefined
: performance?.netPerformancePercentageWithCurrencyEffect
"
>Performance with currency effect</gf-value
>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="mb-5 row">
<div class="col-lg">

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

@ -1,5 +1,6 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
FireCalculationCompleteEvent,
FireWealth,
@ -80,7 +81,7 @@ export class GfFirePageComponent implements OnInit {
: 0
}
};
if (this.user.subscription?.type === 'Basic') {
if (this.user.subscription?.type === SubscriptionType.Basic) {
this.fireWealth = {
today: {
valueInBaseCurrency: 10000
@ -113,7 +114,7 @@ export class GfFirePageComponent implements OnInit {
this.user = state.user;
this.hasPermissionToUpdateUserSettings =
this.user.subscription?.type === 'Basic'
this.user.subscription?.type === SubscriptionType.Basic
? false
: hasPermission(
this.user.permissions,

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

@ -2,6 +2,7 @@ import { GfRulesComponent } from '@ghostfolio/client/components/rules/rules.comp
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { UpdateUserSettingDto } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
PortfolioReportResponse,
PortfolioReportRule
@ -73,7 +74,7 @@ export class GfXRayPageComponent {
this.user = state.user;
this.hasPermissionToUpdateUserSettings =
this.user.subscription?.type === 'Basic'
this.user.subscription?.type === SubscriptionType.Basic
? false
: hasPermission(
this.user.permissions,

BIN
apps/client/src/assets/images/blog/ghostfolio-3.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

7
apps/client/src/assets/oss-friends.json

@ -1,5 +1,5 @@
{
"createdAt": "2025-12-08T00:00:00.000Z",
"createdAt": "2026-04-21T00:00:00.000Z",
"data": [
{
"name": "Activepieces",
@ -21,11 +21,6 @@
"description": "Fastest LLM gateway with adaptive load balancer, cluster mode, guardrails, 1000+ models support & <100 µs overhead at 5k RPS.",
"href": "https://www.getmaxim.ai/bifrost"
},
{
"name": "Cal.com",
"description": "Cal.com is a scheduling tool that helps you schedule meetings without the back-and-forth emails.",
"href": "https://cal.com"
},
{
"name": "Cap",
"description": "Cap is the open source alternative to Loom. Lightweight, powerful, and cross-platform. Record and share securely in seconds.",

83
apps/client/src/styles.scss

@ -1,3 +1,5 @@
@use '@angular/material' as mat;
@import './styles/bootstrap';
@import './styles/table';
@import './styles/variables';
@ -256,46 +258,10 @@ body {
}
}
.mat-mdc-card {
--mat-card-elevated-container-color: var(--dark-background);
--mat-card-outlined-container-color: var(--dark-background);
}
.mat-mdc-fab {
&.mat-primary {
--mat-fab-icon-color: rgba(var(--dark-primary-text));
--mat-mdc-fab-color: rgba(var(--dark-primary-text));
}
}
.mat-mdc-paginator {
background-color: rgba(var(--palette-foreground-base-dark), 0.02);
}
.mat-mdc-slide-toggle {
.mdc-switch__track {
--mat-slide-toggle-selected-focus-track-color: rgba(
255,
255,
255,
0.12
);
--mat-slide-toggle-selected-hover-track-color: rgba(
255,
255,
255,
0.12
);
--mat-slide-toggle-selected-pressed-track-color: rgba(
255,
255,
255,
0.12
);
--mat-slide-toggle-selected-track-color: rgba(255, 255, 255, 0.12);
}
}
.mdc-button {
&.mat-accent,
&.mat-primary {
@ -305,10 +271,6 @@ body {
.page {
&.has-tabs {
.mat-mdc-tab-nav-bar {
--mat-tab-inactive-label-text-color: rgba(var(--light-primary-text));
}
@media (min-width: 576px) {
.mat-mdc-tab-header {
background-color: rgba(var(--palette-foreground-base-dark), 0.02);
@ -443,8 +405,6 @@ ngx-skeleton-loader {
.mat-mdc-card {
.mat-mdc-card-title {
--mat-card-title-text-line-height: 1.2;
margin-bottom: 0.5rem;
}
}
@ -468,15 +428,6 @@ ngx-skeleton-loader {
}
}
.mat-mdc-fab {
color: var(--mat-mdc-fab-color, inherit) !important;
&.mat-primary {
--mat-fab-icon-color: rgba(var(--light-primary-text));
--mat-mdc-fab-color: rgba(var(--light-primary-text));
}
}
.mat-mdc-form-field {
&.without-hint {
.mat-mdc-form-field-subscript-wrapper {
@ -517,15 +468,6 @@ ngx-skeleton-loader {
}
}
.mat-mdc-slide-toggle {
.mdc-switch__track {
--mat-slide-toggle-selected-focus-track-color: rgba(0, 0, 0, 0.12);
--mat-slide-toggle-selected-hover-track-color: rgba(0, 0, 0, 0.12);
--mat-slide-toggle-selected-pressed-track-color: rgba(0, 0, 0, 0.12);
--mat-slide-toggle-selected-track-color: rgba(0, 0, 0, 0.12);
}
}
.mat-stepper-vertical,
.mat-stepper-horizontal {
background: transparent !important;
@ -587,32 +529,23 @@ ngx-skeleton-loader {
}
}
.mat-mdc-tab-nav-bar {
--mat-tab-active-focus-indicator-color: transparent;
--mat-tab-active-hover-indicator-color: transparent;
--mat-tab-inactive-label-text-color: rgba(var(--dark-primary-text));
--mat-tab-active-indicator-color: transparent;
}
.mat-mdc-tab-nav-panel {
padding: 2rem 0;
}
@media (max-width: 575.98px) {
.mat-mdc-tab-link {
--mat-tab-container-height: 3rem;
}
}
@media (min-width: 576px) {
flex-direction: row-reverse;
@include mat.tabs-overrides(
(
divider-height: 0
)
);
.mat-mdc-tab-header {
background-color: rgba(var(--palette-foreground-base), 0.02);
padding: 2rem 0;
width: 14rem;
--mat-tab-label-text-tracking: normal;
--mat-tab-container-height: 2rem;
.mat-mdc-tab-links {
flex-direction: column;

516
apps/client/src/styles/theme.scss

@ -1,109 +1,437 @@
@use '@angular/material' as mat;
@use 'sass:map';
@use './variables.scss' as variables;
$gf-primary: (
50: var(--gf-theme-primary-50),
100: var(--gf-theme-primary-100),
200: var(--gf-theme-primary-200),
300: var(--gf-theme-primary-300),
400: var(--gf-theme-primary-400),
500: var(--gf-theme-primary-500),
600: var(--gf-theme-primary-600),
700: var(--gf-theme-primary-700),
800: var(--gf-theme-primary-800),
900: var(--gf-theme-primary-900),
A100: var(--gf-theme-primary-A100),
A200: var(--gf-theme-primary-A200),
A400: var(--gf-theme-primary-A400),
A700: var(--gf-theme-primary-A700),
contrast: (
50: variables.$dark-primary-text,
100: variables.$dark-primary-text,
200: variables.$dark-primary-text,
300: variables.$light-primary-text,
400: variables.$light-primary-text,
500: variables.$light-primary-text,
600: variables.$light-primary-text,
700: variables.$light-primary-text,
800: variables.$light-primary-text,
900: variables.$light-primary-text,
A100: variables.$dark-primary-text,
A200: variables.$light-primary-text,
A400: variables.$light-primary-text,
A700: variables.$light-primary-text
)
);
$dark-primary-text: rgba(black, 0.87);
$light-primary-text: white;
$gf-secondary: (
50: var(--gf-theme-secondary-50),
100: var(--gf-theme-secondary-100),
200: var(--gf-theme-secondary-200),
300: var(--gf-theme-secondary-300),
400: var(--gf-theme-secondary-400),
500: var(--gf-theme-secondary-500),
600: var(--gf-theme-secondary-600),
700: var(--gf-theme-secondary-700),
800: var(--gf-theme-secondary-800),
900: var(--gf-theme-secondary-900),
A100: var(--gf-theme-secondary-A100),
A200: var(--gf-theme-secondary-A200),
A400: var(--gf-theme-secondary-A400),
A700: var(--gf-theme-secondary-A700),
contrast: (
50: variables.$dark-primary-text,
100: variables.$dark-primary-text,
200: variables.$dark-primary-text,
300: variables.$light-primary-text,
400: variables.$light-primary-text,
500: variables.$light-primary-text,
600: variables.$light-primary-text,
700: variables.$light-primary-text,
800: variables.$light-primary-text,
900: variables.$light-primary-text,
A100: variables.$dark-primary-text,
A200: variables.$light-primary-text,
A400: variables.$light-primary-text,
A700: variables.$light-primary-text
$_palettes: (
primary: (
0: #000000,
10: #00201f,
20: #003736,
25: #004342,
30: #00504e,
35: #005d5b,
40: #006a68,
50: #008583,
60: #00a19f,
70: #11bebc,
80: #47dbd7,
90: #6bf7f4,
95: #affffc,
98: #e3fffd,
99: #f2fffe,
100: #ffffff
),
secondary: (
0: #000000,
10: #001d36,
20: #003258,
25: #003d6a,
30: #00497c,
35: #00558f,
40: #0061a3,
50: #267bc3,
60: #4895df,
70: #66b0fb,
80: #9ecaff,
90: #d1e4ff,
95: #e9f1ff,
98: #f8f9ff,
99: #fdfcff,
100: #ffffff
),
tertiary: (
0: #000000,
10: #031d35,
20: #1b324b,
25: #273d57,
30: #324863,
35: #3e546f,
40: #4a607b,
50: #637995,
60: #7c92b0,
70: #97adcc,
80: #b2c8e8,
90: #d2e4ff,
95: #eaf1ff,
98: #f8f9ff,
99: #fdfcff,
100: #ffffff
),
neutral: (
0: #000000,
10: #191c1c,
20: #2d3131,
25: #383c3c,
30: #444747,
35: #4f5353,
40: #5b5f5e,
50: #747877,
60: #8e9191,
70: #a9acab,
80: #c4c7c6,
90: #e0e3e2,
95: #eff1f0,
98: #f7faf9,
99: #fafdfc,
100: #ffffff,
4: #0b0f0f,
6: #101414,
12: #1d2020,
17: #272b2a,
22: #323535,
24: #363a39,
87: #d8dada,
92: #e6e9e8,
94: #eceeed,
96: #f2f4f3
),
neutral-variant: (
0: #000000,
10: #141d1d,
20: #293232,
25: #343d3d,
30: #3f4948,
35: #4a5454,
40: #566060,
50: #6f7978,
60: #889392,
70: #a3adac,
80: #bec9c7,
90: #dae5e3,
95: #e8f3f2,
98: #f1fbfa,
99: #f4fefd,
100: #ffffff
),
error: (
0: #000000,
10: #410002,
20: #690005,
25: #7e0007,
30: #93000a,
35: #a80710,
40: #ba1a1a,
50: #de3730,
60: #ff5449,
70: #ff897d,
80: #ffb4ab,
90: #ffdad6,
95: #ffedea,
98: #fff8f7,
99: #fffbff,
100: #ffffff
)
);
$gf-typography: mat.m2-define-typography-config();
// Create default theme
$gf-theme-default: mat.m2-define-light-theme(
(
color: (
accent: mat.m2-define-palette($gf-secondary, 500, 900, A100),
primary: mat.m2-define-palette($gf-primary)
),
density: -3,
typography: $gf-typography
)
$_rest: (
secondary: map.get($_palettes, secondary),
neutral: map.get($_palettes, neutral),
neutral-variant: map.get($_palettes, neutral-variant),
error: map.get($_palettes, error)
);
$_primary: map.merge(map.get($_palettes, primary), $_rest);
$_tertiary: map.merge(map.get($_palettes, tertiary), $_rest);
@include mat.all-component-themes($gf-theme-default);
// Create dark theme
$gf-theme-dark: mat.m2-define-dark-theme(
(
color: (
accent: mat.m2-define-palette($gf-secondary, 500, 900, A100),
primary: mat.m2-define-palette($gf-primary)
),
density: -3,
typography: $gf-typography
)
);
@include mat.app-background();
@include mat.button-density(0);
@include mat.elevation-classes();
@include mat.table-density(-1);
// $gf-typography: mat.m2-define-typography-config();
.theme-light {
$gf-theme-default: mat.define-theme(
(
color: (
primary: $_primary,
theme-type: light,
tertiary: $_tertiary
),
density: (
scale: -3
),
// typography: $gf-typography
)
);
@include mat.all-component-themes($gf-theme-default);
@include mat.autocomplete-overrides(
(
background-color: var(--light-background)
)
);
@include mat.button-overrides(
(
outlined-label-text-color: var(--dark-primary-text),
text-label-text-color: var(--dark-primary-text)
)
);
@include mat.button-toggle-overrides(
(
selected-state-background-color: rgba(var(--dark-dividers)),
selected-state-text-color: var(--dark-primary-text)
)
);
@include mat.card-overrides(
(
outlined-container-color: var(--light-background),
outlined-outline-color: rgba(var(--dark-dividers)),
title-text-line-height: 1.2
)
);
@include mat.datepicker-overrides(
(
calendar-container-background-color: var(--light-background)
)
);
@include mat.dialog-overrides(
(
container-color: var(--light-background)
)
);
@include mat.menu-overrides(
(
container-color: var(--light-background)
)
);
@include mat.select-overrides(
(
panel-background-color: var(--light-background)
)
);
@include mat.slide-toggle-overrides(
(
selected-track-outline-color: rgba(var(--dark-dividers)),
track-outline-color: rgba(var(--dark-dividers))
)
);
@include mat.table-overrides(
(
row-item-outline-color: rgba(var(--dark-dividers))
)
);
}
.theme-dark {
@include mat.all-component-colors($gf-theme-dark);
$gf-theme-dark: mat.define-theme(
(
color: (
primary: $_primary,
theme-type: dark,
tertiary: $_tertiary
),
density: (
scale: -3
),
// typography: $gf-typography
)
);
@include mat.all-component-themes($gf-theme-dark);
@include mat.button-overrides(
(
outlined-label-text-color: var(--light-primary-text),
text-label-text-color: var(--light-primary-text)
)
);
@include mat.button-toggle-overrides(
(
selected-state-background-color: rgba(var(--light-dividers)),
selected-state-text-color: var(--light-primary-text)
)
);
@include mat.card-overrides(
(
outlined-container-color: var(--dark-background),
outlined-outline-color: rgba(var(--light-dividers)),
title-text-line-height: 1.2
)
);
@include mat.slide-toggle-overrides(
(
selected-track-outline-color: rgba(var(--light-dividers)),
track-outline-color: rgba(var(--light-dividers))
)
);
@include mat.table-overrides(
(
row-item-outline-color: rgba(var(--light-dividers))
)
);
}
@include mat.button-density(0);
@include mat.elevation-classes();
@include mat.app-background();
@include mat.table-density(-1);
.theme-dark,
.theme-light {
@media (max-width: 575.98px) {
@include mat.dialog-overrides(
(
container-shape: 4px
)
);
.page.has-tabs {
@include mat.tabs-overrides(
(
container-height: 3rem
)
);
}
}
@media (min-width: 576px) {
.page.has-tabs {
@include mat.tabs-overrides(
(
container-height: 2rem
)
);
}
}
@include mat.badge-overrides(
(
background-color: var(--gf-theme-primary-500)
)
);
@include mat.button-overrides(
(
filled-container-color: var(--gf-theme-primary-500),
filled-horizontal-padding: 1rem,
outlined-horizontal-padding: 1rem,
text-horizontal-padding: 1rem
)
);
@include mat.checkbox-overrides(
(
selected-icon-color: var(--gf-theme-primary-500)
)
);
@include mat.datepicker-overrides(
(
calendar-container-elevation-shadow: var(
--mat-select-container-elevation-shadow
),
calendar-date-selected-state-background-color: var(--gf-theme-primary-500),
calendar-date-today-selected-state-outline-color: var(
--gf-theme-primary-500
)
)
);
@include mat.dialog-overrides(
(
container-max-width: 80vw,
container-shape: 8px,
container-small-max-width: 96vw
)
);
@include mat.fab-overrides(
(
container-color: var(--gf-theme-primary-500)
)
);
@include mat.form-field-overrides(
(
outlined-focus-label-text-color: var(--gf-theme-primary-500),
outlined-focus-outline-color: var(--gf-theme-primary-500)
)
);
@include mat.progress-bar-overrides(
(
active-indicator-color: var(--gf-theme-primary-500)
)
);
@include mat.slide-toggle-overrides(
(
selected-focus-handle-color: var(--gf-theme-primary-500),
selected-focus-track-color: transparent,
selected-handle-color: var(--gf-theme-primary-500),
selected-hover-handle-color: var(--gf-theme-primary-500),
selected-hover-track-color: transparent,
selected-pressed-handle-color: var(--gf-theme-primary-500),
selected-pressed-track-color: transparent,
selected-track-color: transparent,
unselected-hover-track-color: transparent,
unselected-track-color: transparent
)
);
@include mat.slider-overrides(
(
active-track-color: var(--gf-theme-primary-500),
focus-handle-color: var(--gf-theme-primary-500),
handle-color: var(--gf-theme-primary-500)
)
);
@include mat.stepper-overrides(
(
header-selected-state-icon-background-color: var(--gf-theme-primary-500)
)
);
@include mat.tabs-overrides(
(
active-focus-label-text-color: var(--gf-theme-primary-500),
active-hover-label-text-color: var(--gf-theme-primary-500),
active-label-text-color: var(--gf-theme-primary-500),
active-ripple-color: var(--gf-theme-primary-500),
inactive-ripple-color: var(--gf-theme-primary-500)
)
);
.mat-accent {
@include mat.button-overrides(
(
filled-container-color: var(--gf-theme-secondary-500),
outlined-label-text-color: var(--gf-theme-secondary-500)
)
);
}
.mat-warn {
@include mat.button-overrides(
(
filled-container-color: #f44336,
filled-label-text-color: white,
outlined-label-text-color: #f44336
)
);
}
.page.has-tabs {
@include mat.tabs-overrides(
(
active-indicator-height: 0,
label-text-tracking: normal
)
);
}
}
:root {
--gf-theme-alpha-hover: 0.04;

1
libs/common/jest.config.ts

@ -2,6 +2,7 @@
export default {
displayName: 'common',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: {},
transform: {
'^.+\\.[tj]sx?$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }]

3
libs/common/src/test-setup.ts

@ -0,0 +1,3 @@
import { TextDecoder, TextEncoder } from 'node:util';
Object.assign(global, { TextDecoder, TextEncoder });

4810
package-lock.json

File diff suppressed because it is too large

51
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.255.0",
"version": "3.0.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -76,17 +76,18 @@
"@keyv/redis": "4.4.0",
"@nestjs/bull": "11.0.4",
"@nestjs/cache-manager": "3.1.0",
"@nestjs/common": "11.1.14",
"@nestjs/config": "4.0.3",
"@nestjs/core": "11.1.14",
"@nestjs/common": "11.1.19",
"@nestjs/config": "4.0.4",
"@nestjs/core": "11.1.19",
"@nestjs/event-emitter": "3.0.1",
"@nestjs/jwt": "11.0.2",
"@nestjs/passport": "11.0.5",
"@nestjs/platform-express": "11.1.14",
"@nestjs/schedule": "6.1.1",
"@nestjs/serve-static": "5.0.4",
"@nestjs/platform-express": "11.1.19",
"@nestjs/schedule": "6.1.3",
"@nestjs/serve-static": "5.0.5",
"@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.19.3",
"@prisma/adapter-pg": "7.7.0",
"@prisma/client": "7.7.0",
"@simplewebauthn/browser": "13.2.2",
"@simplewebauthn/server": "13.2.2",
"ai": "4.3.16",
@ -106,7 +107,7 @@
"cookie-parser": "1.4.7",
"countries-and-timezones": "3.8.0",
"countries-list": "3.3.0",
"countup.js": "2.9.0",
"countup.js": "2.10.0",
"date-fns": "4.1.0",
"dotenv": "17.2.3",
"dotenv-expand": "12.0.3",
@ -117,13 +118,13 @@
"helmet": "7.0.0",
"http-status-codes": "2.3.0",
"ionicons": "8.0.13",
"jsonpath": "1.2.1",
"jsonpath": "1.3.0",
"lodash": "4.18.1",
"marked": "17.0.2",
"ms": "3.0.0-canary.1",
"ng-extract-i18n-merge": "3.3.0",
"ngx-device-detector": "11.0.0",
"ngx-markdown": "21.1.0",
"ngx-markdown": "21.2.0",
"ngx-skeleton-loader": "12.0.0",
"open-color": "1.9.1",
"papaparse": "5.3.1",
@ -155,18 +156,18 @@
"@angular/pwa": "21.2.6",
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.35.0",
"@nestjs/schematics": "11.0.9",
"@nestjs/testing": "11.1.14",
"@nx/angular": "22.6.4",
"@nx/eslint-plugin": "22.6.4",
"@nx/jest": "22.6.4",
"@nx/js": "22.6.4",
"@nx/module-federation": "22.6.4",
"@nx/nest": "22.6.4",
"@nx/node": "22.6.4",
"@nx/storybook": "22.6.4",
"@nx/web": "22.6.4",
"@nx/workspace": "22.6.4",
"@nestjs/schematics": "11.1.0",
"@nestjs/testing": "11.1.19",
"@nx/angular": "22.6.5",
"@nx/eslint-plugin": "22.6.5",
"@nx/jest": "22.6.5",
"@nx/js": "22.6.5",
"@nx/module-federation": "22.6.5",
"@nx/nest": "22.6.5",
"@nx/node": "22.6.5",
"@nx/storybook": "22.6.5",
"@nx/web": "22.6.5",
"@nx/workspace": "22.6.5",
"@schematics/angular": "21.2.6",
"@storybook/addon-docs": "10.1.10",
"@storybook/angular": "10.1.10",
@ -192,10 +193,10 @@
"jest": "30.2.0",
"jest-environment-jsdom": "30.2.0",
"jest-preset-angular": "16.0.0",
"nx": "22.6.4",
"nx": "22.6.5",
"prettier": "3.8.2",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.19.3",
"prisma": "7.7.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.4.0",

1
prisma/schema.prisma

@ -6,7 +6,6 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Access {

7
prisma/seed.mts

@ -1,6 +1,11 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL
});
const prisma = new PrismaClient({ adapter });
async function main() {
await prisma.tag.createMany({

Loading…
Cancel
Save