Browse Source

Merge remote-tracking branch 'origin/main' into bugfix/portfolio-calculator-precision-issues

pull/5509/head
KenTandrian 6 days ago
parent
commit
47e0a510fc
  1. 17
      CHANGELOG.md
  2. 18
      Dockerfile
  3. 34
      apps/api/src/app/admin/admin.service.ts
  4. 2
      apps/api/src/app/app.module.ts
  5. 4
      apps/api/src/app/endpoints/assets/assets.controller.ts
  6. 4
      apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
  7. 12
      apps/api/src/app/order/order.service.ts
  8. 2
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  9. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  10. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  11. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  12. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  13. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  14. 2
      apps/api/src/app/redis-cache/redis-cache.service.ts
  15. 2
      apps/api/src/app/user/user.service.ts
  16. 2
      apps/api/src/helper/string.helper.ts
  17. 6
      apps/api/src/middlewares/html-template.middleware.ts
  18. 2
      apps/api/src/services/api-key/api-key.service.ts
  19. 85
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  20. 4
      apps/api/src/services/i18n/i18n.service.ts
  21. 3
      apps/client/src/app/pages/faq/overview/faq-overview-page.html
  22. 20
      apps/client/src/app/pages/public/public-page.html
  23. 12
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  24. 46
      libs/common/src/lib/personal-finance-tools.ts
  25. 20
      libs/ui/src/lib/entity-logo/entity-logo-image-source.service.ts
  26. 44
      libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts
  27. 13
      libs/ui/src/lib/entity-logo/entity-logo.component.ts
  28. 24
      libs/ui/src/lib/mocks/entity-logo-image-source.service.mock.ts
  29. 12
      package-lock.json
  30. 7
      package.json
  31. 11
      prisma.config.ts

17
CHANGELOG.md

@ -5,11 +5,26 @@ 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.199.0 - 2025-09-14
### Added
- Extended the content of the performance calculation method by dividends on the Frequently Asked Questions (FAQ) page
- Added a _Storybook_ story for the entity logo image component
### Changed ### Changed
- Improved the search in the _Yahoo Finance_ service
- Moved the holdings table into the holdings section on the public page
- Migrated to the _Prisma Configuration File_ approach (`prisma.config.ts`)
- Refactored the login with access token dialog component to standalone - Refactored the login with access token dialog component to standalone
- Prefixed the `crypto`, `fs` and `path` imports with `node:`
- Upgraded `yahoo-finance2` from version `3.8.0` to `3.10.0`
### Fixed
- Fixed a pagination issue in the market data endpoint by adding `id` as a secondary sort criterion to ensure consistent ordering in the admin control panel
- Fixed a pagination issue in the user endpoint by adding `id` as a secondary sort criterion to ensure consistent ordering in the admin control panel
## 2.198.0 - 2025-09-11 ## 2.198.0 - 2025-09-11

18
Dockerfile

@ -17,7 +17,8 @@ COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE COPY ./LICENSE LICENSE
COPY ./package.json package.json COPY ./package.json package.json
COPY ./package-lock.json package-lock.json COPY ./package-lock.json package-lock.json
COPY ./prisma/schema.prisma prisma/schema.prisma COPY ./prisma.config.ts prisma.config.ts
COPY ./prisma/schema.prisma prisma/
RUN npm install RUN npm install
@ -25,8 +26,8 @@ RUN npm install
COPY ./decorate-angular-cli.js decorate-angular-cli.js COPY ./decorate-angular-cli.js decorate-angular-cli.js
RUN node decorate-angular-cli.js RUN node decorate-angular-cli.js
COPY ./apps apps COPY ./apps apps/
COPY ./libs libs COPY ./libs libs/
COPY ./jest.config.ts jest.config.ts COPY ./jest.config.ts jest.config.ts
COPY ./jest.preset.js jest.preset.js COPY ./jest.preset.js jest.preset.js
COPY ./nx.json nx.json COPY ./nx.json nx.json
@ -40,14 +41,15 @@ RUN npm run build:production
WORKDIR /ghostfolio/dist/apps/api WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original # package.json was generated by the build process, however the original
# package-lock.json needs to be used to ensure the same versions # package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json COPY ./package-lock.json /ghostfolio/dist/apps/api/
RUN npm install RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma COPY prisma.config.ts /ghostfolio/dist/apps/api/
COPY prisma /ghostfolio/dist/apps/api/prisma/
# Overwrite the generated package.json with the original one to ensure having # Overwrite the generated package.json with the original one to ensure having
# all the scripts # all the scripts
COPY package.json /ghostfolio/dist/apps/api COPY package.json /ghostfolio/dist/apps/api/
RUN npm run database:generate-typings RUN npm run database:generate-typings
# Image to run, copy everything needed from builder # Image to run, copy everything needed from builder
@ -60,8 +62,8 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
openssl \ openssl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps/
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333} EXPOSE ${PORT:-3333}
USER node USER node

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

@ -192,7 +192,7 @@ export class AdminService {
filters, filters,
presetId, presetId,
sortColumn, sortColumn,
sortDirection, sortDirection = 'asc',
skip, skip,
take = Number.MAX_SAFE_INTEGER take = Number.MAX_SAFE_INTEGER
}: { }: {
@ -262,11 +262,13 @@ export class AdminService {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') { if (sortColumn === 'activitiesCount') {
orderBy = { orderBy = [
activities: { {
_count: sortDirection activities: {
_count: sortDirection
}
} }
}; ];
} }
} }
@ -275,10 +277,10 @@ export class AdminService {
try { try {
const symbolProfileResult = await Promise.all([ const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({ extendedPrismaClient.symbolProfile.findMany({
orderBy,
skip, skip,
take, take,
where, where,
orderBy: [...orderBy, { id: sortDirection }],
select: { select: {
_count: { _count: {
select: { select: {
@ -817,17 +819,21 @@ export class AdminService {
skip?: number; skip?: number;
take?: number; take?: number;
}): Promise<AdminUsers['users']> { }): Promise<AdminUsers['users']> {
let orderBy: Prisma.UserOrderByWithRelationInput = { let orderBy: Prisma.Enumerable<Prisma.UserOrderByWithRelationInput> = [
createdAt: 'desc' { createdAt: 'desc' }
}; ];
let where: Prisma.UserWhereInput; let where: Prisma.UserWhereInput;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = { orderBy = [
analytics: { {
lastRequestAt: 'desc' analytics: {
lastRequestAt: 'desc'
}
} }
}; ];
where = { where = {
NOT: { NOT: {
analytics: null analytics: null
@ -836,10 +842,10 @@ export class AdminService {
} }
const usersWithAnalytics = await this.prismaService.user.findMany({ const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy,
skip, skip,
take, take,
where, where,
orderBy: [...orderBy, { id: 'desc' }],
select: { select: {
_count: { _count: {
select: { accounts: true, activities: true } select: { accounts: true, activities: true }

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

@ -21,7 +21,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { join } from 'path'; import { join } from 'node:path';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';

4
apps/api/src/app/endpoints/assets/assets.controller.ts

@ -10,8 +10,8 @@ import {
VERSION_NEUTRAL VERSION_NEUTRAL
} from '@nestjs/common'; } from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
import { readFileSync } from 'fs'; import { readFileSync } from 'node:fs';
import { join } from 'path'; import { join } from 'node:path';
@Controller('assets') @Controller('assets')
export class AssetsController { export class AssetsController {

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

@ -8,8 +8,8 @@ import {
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common'; import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Response } from 'express'; import { Response } from 'express';
import { readFileSync } from 'fs'; import { readFileSync } from 'node:fs';
import { join } from 'path'; import { join } from 'node:path';
import { SitemapService } from './sitemap.service'; import { SitemapService } from './sitemap.service';

12
apps/api/src/app/order/order.service.ts

@ -325,7 +325,7 @@ export class OrderService {
includeDrafts = false, includeDrafts = false,
skip, skip,
sortColumn, sortColumn,
sortDirection, sortDirection = 'asc',
startDate, startDate,
take = Number.MAX_SAFE_INTEGER, take = Number.MAX_SAFE_INTEGER,
types, types,
@ -347,9 +347,9 @@ export class OrderService {
withExcludedAccountsAndActivities?: boolean; withExcludedAccountsAndActivities?: boolean;
}): Promise<Activities> { }): Promise<Activities> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [ let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' }, { date: 'asc' }
{ id: 'asc' }
]; ];
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
if (endDate || startDate) { if (endDate || startDate) {
@ -483,7 +483,7 @@ export class OrderService {
} }
if (sortColumn) { if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
} }
if (types) { if (types) {
@ -506,7 +506,6 @@ export class OrderService {
const [orders, count] = await Promise.all([ const [orders, count] = await Promise.all([
this.orders({ this.orders({
orderBy,
skip, skip,
take, take,
where, where,
@ -519,7 +518,8 @@ export class OrderService {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true, SymbolProfile: true,
tags: true tags: true
} },
orderBy: [...orderBy, { id: sortDirection }]
}), }),
this.prismaService.order.count({ where }) this.prismaService.order.count({ where })
]); ]);

2
apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts

@ -1,4 +1,4 @@
import { readFileSync } from 'fs'; import { readFileSync } from 'node:fs';
export const activityDummyData = { export const activityDummyData = {
accountId: undefined, accountId: undefined,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -20,7 +20,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts

@ -20,7 +20,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -20,7 +20,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -20,7 +20,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -20,7 +20,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {

2
apps/api/src/app/redis-cache/redis-cache.service.ts

@ -4,9 +4,9 @@ import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager'; import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import Keyv from 'keyv'; import Keyv from 'keyv';
import ms from 'ms'; import ms from 'ms';
import { createHash } from 'node:crypto';
@Injectable() @Injectable()
export class RedisCacheService { export class RedisCacheService {

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

@ -48,9 +48,9 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { createHmac } from 'crypto';
import { differenceInDays, subDays } from 'date-fns'; import { differenceInDays, subDays } from 'date-fns';
import { sortBy, without } from 'lodash'; import { sortBy, without } from 'lodash';
import { createHmac } from 'node:crypto';
@Injectable() @Injectable()
export class UserService { export class UserService {

2
apps/api/src/helper/string.helper.ts

@ -1,4 +1,4 @@
import { randomBytes } from 'crypto'; import { randomBytes } from 'node:crypto';
export function getRandomString(length: number) { export function getRandomString(length: number) {
const bytes = randomBytes(length); const bytes = randomBytes(length);

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

@ -10,8 +10,8 @@ import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import * as fs from 'fs'; import { readFileSync } from 'node:fs';
import { join } from 'path'; import { join } from 'node:path';
const title = 'Ghostfolio'; const title = 'Ghostfolio';
@ -87,7 +87,7 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce( this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({ (map, languageCode) => ({
...map, ...map,
[languageCode]: fs.readFileSync( [languageCode]: readFileSync(
join(__dirname, '..', 'client', languageCode, 'index.html'), join(__dirname, '..', 'client', languageCode, 'index.html'),
'utf8' 'utf8'
) )

2
apps/api/src/services/api-key/api-key.service.ts

@ -3,7 +3,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { ApiKeyResponse } from '@ghostfolio/common/interfaces'; import { ApiKeyResponse } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { pbkdf2Sync } from 'crypto'; import { pbkdf2Sync } from 'node:crypto';
@Injectable() @Injectable()
export class ApiKeyService { export class ApiKeyService {

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

@ -24,6 +24,7 @@ import {
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { addDays, format, isSameDay } from 'date-fns'; import { addDays, format, isSameDay } from 'date-fns';
import { uniqBy } from 'lodash';
import YahooFinance from 'yahoo-finance2'; import YahooFinance from 'yahoo-finance2';
import { ChartResultArray } from 'yahoo-finance2/esm/src/modules/chart'; import { ChartResultArray } from 'yahoo-finance2/esm/src/modules/chart';
import { import {
@ -34,6 +35,10 @@ import {
Quote, Quote,
QuoteResponseArray QuoteResponseArray
} from 'yahoo-finance2/esm/src/modules/quote'; } from 'yahoo-finance2/esm/src/modules/quote';
import {
Price,
QuoteSummaryResult
} from 'yahoo-finance2/esm/src/modules/quoteSummary';
import { SearchQuoteNonYahoo } from 'yahoo-finance2/esm/src/modules/search'; import { SearchQuoteNonYahoo } from 'yahoo-finance2/esm/src/modules/search';
@Injectable() @Injectable()
@ -190,10 +195,7 @@ export class YahooFinanceService implements DataProviderInterface {
); );
try { try {
let quotes: Pick< let quotes: Price[] | Quote[] = [];
Quote,
'currency' | 'marketState' | 'regularMarketPrice' | 'symbol'
>[] = [];
try { try {
quotes = await this.yahooFinance.quote(yahooFinanceSymbols); quotes = await this.yahooFinance.quote(yahooFinanceSymbols);
@ -290,7 +292,9 @@ export class YahooFinanceService implements DataProviderInterface {
try { try {
marketData = await this.yahooFinance.quote( marketData = await this.yahooFinance.quote(
quotes.map(({ symbol }) => { uniqBy(quotes, ({ symbol }) => {
return symbol;
}).map(({ symbol }) => {
return symbol; return symbol;
}) })
); );
@ -300,35 +304,35 @@ export class YahooFinanceService implements DataProviderInterface {
} }
} }
for (const marketDataItem of marketData) { for (const {
const quote = quotes.find((currentQuote) => { currency,
return currentQuote.symbol === marketDataItem.symbol; longName,
}); quoteType,
shortName,
const symbol = symbol
this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol( } of marketData) {
marketDataItem.symbol
);
const { assetClass, assetSubClass } = const { assetClass, assetSubClass } =
this.yahooFinanceDataEnhancerService.parseAssetClass({ this.yahooFinanceDataEnhancerService.parseAssetClass({
quoteType: quote.quoteType, quoteType,
shortName: quote.shortname shortName
}); });
items.push({ items.push({
assetClass, assetClass,
assetSubClass, assetSubClass,
symbol, currency,
currency: marketDataItem.currency,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName(), dataSource: this.getName(),
name: this.yahooFinanceDataEnhancerService.formatName({ name: this.yahooFinanceDataEnhancerService.formatName({
longName: quote.longname, longName,
quoteType: quote.quoteType, quoteType,
shortName: quote.shortname, shortName,
symbol: quote.symbol symbol
}) }),
symbol:
this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
symbol
)
}); });
} }
} catch (error) { } catch (error) {
@ -354,23 +358,28 @@ export class YahooFinanceService implements DataProviderInterface {
private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) { private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) {
const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => { const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => {
return this.yahooFinance.quoteSummary(symbol).catch(() => { return this.yahooFinance.quoteSummary(symbol);
Logger.error(
`Could not get quote summary for ${symbol}`,
'YahooFinanceService'
);
return null;
});
}); });
const quoteSummaryItems = await Promise.all(quoteSummaryPromises); const settledResults = await Promise.allSettled(quoteSummaryPromises);
return settledResults
.filter(
(result): result is PromiseFulfilledResult<QuoteSummaryResult> => {
if (result.status === 'rejected') {
Logger.error(
`Could not get quote summary: ${result.reason}`,
'YahooFinanceService'
);
return quoteSummaryItems return false;
.filter((item) => { }
return item !== null;
}) return true;
.map(({ price }) => { }
return price; )
.map(({ value }) => {
return value.price;
}); });
} }
} }

4
apps/api/src/services/i18n/i18n.service.ts

@ -2,8 +2,8 @@ import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { readFileSync, readdirSync } from 'fs'; import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'path'; import { join } from 'node:path';
@Injectable() @Injectable()
export class I18nService { export class I18nService {

3
apps/client/src/app/pages/faq/overview/faq-overview-page.html

@ -52,7 +52,8 @@
calculation method based on the average amount of capital invested calculation method based on the average amount of capital invested
over time. ROAI aims to provide a more insightful view of investment over time. ROAI aims to provide a more insightful view of investment
performance than simpler approaches, especially when contributions are performance than simpler approaches, especially when contributions are
made over time.</mat-card-content made over time. Dividends are not part of the
calculation.</mat-card-content
> >
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">

20
apps/client/src/app/pages/public/public-page.html

@ -77,6 +77,14 @@
[keys]="['symbol']" [keys]="['symbol']"
[showLabels]="deviceType !== 'mobile'" [showLabels]="deviceType !== 'mobile'"
/> />
<gf-holdings-table
[deviceType]="deviceType"
[hasPermissionToOpenDetails]="false"
[hasPermissionToShowQuantities]="false"
[hasPermissionToShowValues]="false"
[holdings]="holdings"
[pageSize]="7"
/>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
@ -195,18 +203,6 @@
</div> </div>
</div> </div>
} }
<div class="row">
<div class="col-lg">
<gf-holdings-table
[deviceType]="deviceType"
[hasPermissionToOpenDetails]="false"
[hasPermissionToShowQuantities]="false"
[hasPermissionToShowValues]="false"
[holdings]="holdings"
[pageSize]="7"
/>
</div>
</div>
<div class="row my-5"> <div class="row my-5">
<div class="col-md-10 offset-md-1"> <div class="col-md-10 offset-md-1">
<h2 class="h4 mb-1 text-center" i18n> <h2 class="h4 mb-1 text-center" i18n>

12
apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts

@ -76,7 +76,9 @@ export class GfProductPageComponent implements OnInit {
this.tags = [ this.tags = [
this.product1.name, this.product1.name,
this.product1.origin,
this.product2.name, this.product2.name,
this.product2.origin,
$localize`Alternative`, $localize`Alternative`,
$localize`App`, $localize`App`,
$localize`Budgeting`, $localize`Budgeting`,
@ -96,8 +98,12 @@ export class GfProductPageComponent implements OnInit {
$localize`Wealth`, $localize`Wealth`,
$localize`Wealth Management`, $localize`Wealth Management`,
`WealthTech` `WealthTech`
].sort((a, b) => { ]
return a.localeCompare(b, undefined, { sensitivity: 'base' }); .filter((item) => {
}); return !!item;
})
.sort((a, b) => {
return a.localeCompare(b, undefined, { sensitivity: 'base' });
});
} }
} }

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

@ -33,6 +33,16 @@ export const personalFinanceTools: Product[] = [
origin: 'Switzerland', origin: 'Switzerland',
slogan: 'Simplicity for Complex Wealth' slogan: 'Simplicity for Complex Wealth'
}, },
{
founded: 2023,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'amsflow',
name: 'Amsflow Portfolio',
origin: 'Singapore',
pricingPerYear: '$228',
slogan: 'Portfolio Visualizer'
},
{ {
founded: 2018, founded: 2018,
hasFreePlan: true, hasFreePlan: true,
@ -97,6 +107,12 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$100', pricingPerYear: '$100',
slogan: 'Stock Portfolio Tracker for Smart Investors' slogan: 'Stock Portfolio Tracker for Smart Investors'
}, },
{
key: 'budgetpulse',
name: 'BudgetPulse',
origin: 'United States',
slogan: 'Giving life to your finance!'
},
{ {
founded: 2007, founded: 2007,
hasFreePlan: false, hasFreePlan: false,
@ -180,6 +196,15 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$95', pricingPerYear: '$95',
slogan: 'Do money better with Copilot' slogan: 'Do money better with Copilot'
}, },
{
founded: 2014,
hasFreePlan: false,
key: 'countabout',
name: 'CountAbout',
origin: 'United States',
pricingPerYear: '$9.99',
slogan: 'Customizable and Secure Personal Finance App'
},
{ {
founded: 2023, founded: 2023,
hasFreePlan: false, hasFreePlan: false,
@ -427,6 +452,14 @@ export const personalFinanceTools: Product[] = [
slogan: 'Die All-in-One Lösung für dein Vermögen.', slogan: 'Die All-in-One Lösung für dein Vermögen.',
useAnonymously: true useAnonymously: true
}, },
{
founded: 2017,
hasSelfHostingAbility: false,
key: 'honeydue',
name: 'Honeydue',
origin: 'United States',
slogan: 'Finance App for Couples'
},
{ {
founded: 2022, founded: 2022,
key: 'income-reign', key: 'income-reign',
@ -608,6 +641,13 @@ export const personalFinanceTools: Product[] = [
origin: 'Germany', origin: 'Germany',
slogan: 'Dein smarter Finance Assistant' slogan: 'Dein smarter Finance Assistant'
}, },
{
key: 'moneywiz',
name: 'MoneyWiz',
origin: 'United States',
pricingPerYear: '$29.99',
slogan: 'Get money management superpowers'
},
{ {
hasFreePlan: false, hasFreePlan: false,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
@ -859,6 +899,12 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$80', pricingPerYear: '$80',
slogan: 'Simple and powerful portfolio tracker' slogan: 'Simple and powerful portfolio tracker'
}, },
{
key: 'splashmoney',
name: 'SplashMoney',
origin: 'United States',
slogan: 'Manage your money anytime, anywhere.'
},
{ {
founded: 2019, founded: 2019,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,

20
libs/ui/src/lib/entity-logo/entity-logo-image-source.service.ts

@ -0,0 +1,20 @@
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable } from '@angular/core';
@Injectable({
// Required to allow mocking in Storybook
providedIn: 'root'
})
export class EntityLogoImageSourceService {
public getLogoUrlByAssetProfileIdentifier({
dataSource,
symbol
}: AssetProfileIdentifier) {
return `../api/v1/logo/${dataSource}/${symbol}`;
}
public getLogoUrlByUrl(url: string) {
return `../api/v1/logo?url=${url}`;
}
}

44
libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts

@ -0,0 +1,44 @@
import { CommonModule } from '@angular/common';
import { importProvidersFrom } from '@angular/core';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { applicationConfig, Meta, StoryObj } from '@storybook/angular';
import { EntityLogoImageSourceServiceMock } from '../mocks/entity-logo-image-source.service.mock';
import { EntityLogoImageSourceService } from './entity-logo-image-source.service';
import { GfEntityLogoComponent } from './entity-logo.component';
export default {
title: 'Entity Logo',
component: GfEntityLogoComponent,
decorators: [
applicationConfig({
providers: [
provideNoopAnimations(),
importProvidersFrom(CommonModule),
{
provide: EntityLogoImageSourceService,
useValue: new EntityLogoImageSourceServiceMock()
}
]
})
]
} as Meta<GfEntityLogoComponent>;
type Story = StoryObj<GfEntityLogoComponent>;
export const LogoByAssetProfileIdentifier: Story = {
args: {
dataSource: 'YAHOO',
size: 'large',
symbol: 'AAPL',
tooltip: 'Apple Inc.'
}
};
export const LogoByUrl: Story = {
args: {
size: 'large',
tooltip: 'Ghostfolio',
url: 'https://ghostfol.io'
}
};

13
libs/ui/src/lib/entity-logo/entity-logo.component.ts

@ -1,3 +1,5 @@
import { EntityLogoImageSourceService } from '@ghostfolio/ui/entity-logo/entity-logo-image-source.service';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
@ -25,11 +27,18 @@ export class GfEntityLogoComponent implements OnChanges {
public src: string; public src: string;
public constructor(
private readonly imageSourceService: EntityLogoImageSourceService
) {}
public ngOnChanges() { public ngOnChanges() {
if (this.dataSource && this.symbol) { if (this.dataSource && this.symbol) {
this.src = `../api/v1/logo/${this.dataSource}/${this.symbol}`; this.src = this.imageSourceService.getLogoUrlByAssetProfileIdentifier({
dataSource: this.dataSource,
symbol: this.symbol
});
} else if (this.url) { } else if (this.url) {
this.src = `../api/v1/logo?url=${this.url}`; this.src = this.imageSourceService.getLogoUrlByUrl(this.url);
} }
} }
} }

24
libs/ui/src/lib/mocks/entity-logo-image-source.service.mock.ts

@ -0,0 +1,24 @@
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
export class EntityLogoImageSourceServiceMock {
public getLogoUrlByAssetProfileIdentifier({
dataSource,
symbol
}: AssetProfileIdentifier) {
if (dataSource === DataSource.YAHOO && symbol === 'AAPL') {
return 'data:image/png;base64,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';
}
return '';
}
public getLogoUrlByUrl(url: string) {
if (url === 'https://ghostfol.io') {
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAP1BMVEU2z8v////x/Pspzcn0/Px73drR8/KC3tz6/v6e5eOl5+WM4d7n+PhI08/i9/eu6edV1dJk2NVx2teV4+G87evvttLSAAABDElEQVRYhe2V3Y6EIAyFrQXlTwHd93/WpeBsdiKbtF7tJJwbCKFfDlKO0zQ0NPQJwqrn1ZO2zlk9PWPgusClRcsJmHb4pWUTItDDu4zMBJ5w0yog4HGvB0gCgOoBZrYFdL16AMsmmD5AcQ3ofj3AwbOAX38BIhMw02ZjtQ+tLnht66mCAGA2eka1G3eabUZwD/PLbeuHenKMBODVV0Dru/TTQLgKAc1BvQ/9yAEkOnlon66oehEBIHp7dbSyPoIc0NEADMDnAa4wAvVK+CADRGx/VpNSi+iF3jMTUH4rJQ0aISPmVk+JoJiZeJw1QnaqL2OmWKSFkxnrZWsbXK4TzO5tmS+8TYaGhv6xvgEEfAgHGc7HRgAAAABJRU5ErkJggg==';
}
return '';
}
}

12
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.198.0", "version": "2.199.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.198.0", "version": "2.199.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -93,7 +93,7 @@
"svgmap": "2.12.2", "svgmap": "2.12.2",
"twitter-api-v2": "1.23.0", "twitter-api-v2": "1.23.0",
"uuid": "11.1.0", "uuid": "11.1.0",
"yahoo-finance2": "3.8.0", "yahoo-finance2": "3.10.0",
"zone.js": "0.15.1" "zone.js": "0.15.1"
}, },
"devDependencies": { "devDependencies": {
@ -42071,9 +42071,9 @@
} }
}, },
"node_modules/yahoo-finance2": { "node_modules/yahoo-finance2": {
"version": "3.8.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.8.0.tgz", "resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.10.0.tgz",
"integrity": "sha512-em11JOlfSg23wevm4kXs1+A/CoSWD9eg7/hKRU3zKWuPknCfE4NkIhGVb601Nokid+KPE8Q0eoXK4qgLsMIjKA==", "integrity": "sha512-0mnvefEAapMS6M3tnqLmQlyE2W38AQqByaTS09l2dawLaVU7NNc0hJ4qI4F3qi3C7MU+ZWAb8DFVKpW6Zsj0Nw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@deno/shim-deno": "~0.18.0", "@deno/shim-deno": "~0.18.0",

7
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.198.0", "version": "2.199.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",
@ -139,7 +139,7 @@
"svgmap": "2.12.2", "svgmap": "2.12.2",
"twitter-api-v2": "1.23.0", "twitter-api-v2": "1.23.0",
"uuid": "11.1.0", "uuid": "11.1.0",
"yahoo-finance2": "3.8.0", "yahoo-finance2": "3.10.0",
"zone.js": "0.15.1" "zone.js": "0.15.1"
}, },
"devDependencies": { "devDependencies": {
@ -209,8 +209,5 @@
}, },
"engines": { "engines": {
"node": ">=22.18.0" "node": ">=22.18.0"
},
"prisma": {
"seed": "node prisma/seed.mts"
} }
} }

11
prisma.config.ts

@ -0,0 +1,11 @@
import 'dotenv/config';
import { join } from 'node:path';
import { defineConfig } from 'prisma/config';
export default defineConfig({
migrations: {
path: join('prisma', 'migrations'),
seed: `node ${join('prisma', 'seed.mts')}`
},
schema: join('prisma', 'schema.prisma')
});
Loading…
Cancel
Save