From d83578e2232a7ada99f2a9798b2baa0decfb2bbe Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:42:56 +0530 Subject: [PATCH 1/2] fix(portfolio): tolerate unconvertible fees in the calculator `toCurrencyAtDate()` resolves to undefined when no exchange rate exists for an activity's date, so `feeInAssetProfileCurrency` and `feeInBaseCurrency` can reach the calculator unset. Passing those straight into `new Big()` threw "[big.js] Invalid number" and failed the whole portfolio request, so the Overview, Portfolio and Holdings pages rendered "Oops! Something went wrong". Fees are only ever accumulated, so 0 is the identity here and the snapshot stays computable while the exchange rate data is still being gathered. Refs ghostfolio/ghostfolio#6482 --- .../src/app/portfolio/calculator/portfolio-calculator.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index cee94f020..75dda5cf2 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -145,8 +145,13 @@ export abstract class PortfolioCalculator { tags, type, date: format(date, DATE_FORMAT), - fee: new Big(feeInAssetProfileCurrency), - feeInBaseCurrency: new Big(feeInBaseCurrency), + // Fees are converted via the exchange rate data service, which + // yields undefined when no rate is available for the activity’s + // date. Fees are only ever accumulated, so falling back to 0 keeps + // the portfolio computable instead of failing the whole request + // with “[big.js] Invalid number”. + fee: new Big(feeInAssetProfileCurrency ?? 0), + feeInBaseCurrency: new Big(feeInBaseCurrency ?? 0), quantity: new Big(quantity), unitPrice: new Big(unitPriceInAssetProfileCurrency) }; From 2c5f4fdca2d9e7204ac8cf0aed38586d797d6191 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:43:34 +0530 Subject: [PATCH 2/2] test(portfolio): cover a missing exchange rate in the calculator Builds the BALN.SW buy with both fee fields unset, which is the state the calculator sees when no exchange rate is available for the activity's date. Asserts the snapshot is still produced and the fees count as 0. Fails with "[big.js] Invalid number" without the preceding fix. --- ...tor-baln-buy-without-exchange-rate.spec.ts | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-without-exchange-rate.spec.ts diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-without-exchange-rate.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-without-exchange-rate.spec.ts new file mode 100644 index 000000000..e1d44a078 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-without-exchange-rate.spec.ts @@ -0,0 +1,141 @@ +import { + activityDummyData, + assetProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy and a missing exchange rate', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + // ExchangeRateDataService.toCurrencyAtDate() resolves to undefined when + // no exchange rate is available for the activity’s date, which leaves the + // converted fee fields undefined by the time they reach the calculator. + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: undefined, + feeInBaseCurrency: undefined, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 136.6 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + // The snapshot is still computed; the unconvertible fees count as 0 + // rather than aborting the request with “[big.js] Invalid number”. + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('297.8'), + errors: [], + hasErrors: false, + totalFeesWithCurrencyEffect: new Big('0'), + positions: [ + { + activitiesCount: 1, + averagePrice: new Big('136.6'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-30', + fee: new Big('0'), + feeInBaseCurrency: new Big('0'), + investment: new Big('273.2'), + quantity: new Big('2'), + symbol: 'BALN.SW', + valueInBaseCurrency: new Big('297.8') + } + ] + }); + }); + }); +});