From 77c82ebb526291c724e883e3f2c8e5af0cdca444 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sun, 8 Feb 2026 21:10:18 -0800 Subject: [PATCH] fix: include valueInBaseCurrency in import endpoint response Resolves #6291 The `POST api/v1/import` endpoint was missing the `valueInBaseCurrency` field in the activity response. Added `ExchangeRateDataService` to calculate this value using the same pattern as `OrderService.getOrders()`, converting the activity value from its currency to the user's base currency at the activity's date. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + .../api/src/app/import/import.service.spec.ts | 210 ++++++++++++++++++ apps/api/src/app/import/import.service.ts | 10 + 3 files changed, 221 insertions(+) create mode 100644 apps/api/src/app/import/import.service.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ddad0b5..db35e907f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed the missing `valueInBaseCurrency` in the import endpoint (`POST api/v1/import`) - Fixed the accounts of the assistant for the impersonation mode - Fixed the tags of the assistant for the impersonation mode diff --git a/apps/api/src/app/import/import.service.spec.ts b/apps/api/src/app/import/import.service.spec.ts new file mode 100644 index 000000000..deaa5d132 --- /dev/null +++ b/apps/api/src/app/import/import.service.spec.ts @@ -0,0 +1,210 @@ +import { ImportService } from './import.service'; + +describe('ImportService', () => { + let importService: ImportService; + + let exchangeRateDataServiceMock: { toCurrencyAtDate: jest.Mock }; + let dataProviderServiceMock: { + getDataSourceForImport: jest.Mock; + getDataSources: jest.Mock; + getAssetProfiles: jest.Mock; + }; + let orderServiceMock: { getOrders: jest.Mock }; + let accountServiceMock: { getAccounts: jest.Mock }; + let tagServiceMock: { getTagsForUser: jest.Mock }; + let configurationServiceMock: { get: jest.Mock }; + + beforeEach(() => { + exchangeRateDataServiceMock = { + toCurrencyAtDate: jest.fn() + }; + + dataProviderServiceMock = { + getDataSourceForImport: jest.fn().mockReturnValue('YAHOO'), + getDataSources: jest.fn().mockResolvedValue(['MANUAL', 'YAHOO']), + getAssetProfiles: jest.fn() + }; + + orderServiceMock = { + getOrders: jest.fn().mockResolvedValue({ activities: [], count: 0 }) + }; + + accountServiceMock = { + getAccounts: jest.fn().mockResolvedValue([]) + }; + + tagServiceMock = { + getTagsForUser: jest.fn().mockResolvedValue([]) + }; + + configurationServiceMock = { + get: jest.fn().mockImplementation((key: string) => { + if (key === 'ENABLE_FEATURE_SUBSCRIPTION') { + return false; + } + + if (key === 'MAX_ACTIVITIES_TO_IMPORT') { + return Number.MAX_SAFE_INTEGER; + } + + return undefined; + }) + }; + + importService = new ImportService( + accountServiceMock as any, + null, + configurationServiceMock as any, + null, + dataProviderServiceMock as any, + exchangeRateDataServiceMock as any, + null, + orderServiceMock as any, + null, + null, + null, + tagServiceMock as any + ); + }); + + describe('import', () => { + it('should include valueInBaseCurrency for a dry-run FEE activity', async () => { + const expectedValueInBaseCurrency = 15; + + exchangeRateDataServiceMock.toCurrencyAtDate.mockResolvedValue( + expectedValueInBaseCurrency + ); + + const activities = await importService.import({ + isDryRun: true, + maxActivitiesToImport: Number.MAX_SAFE_INTEGER, + accountsWithBalancesDto: [], + activitiesDto: [ + { + currency: 'USD', + dataSource: 'MANUAL', + date: '2024-01-15T00:00:00.000Z', + fee: 0, + quantity: 1, + symbol: 'Account Opening Fee', + type: 'FEE', + unitPrice: 15 + } as any + ], + assetProfilesWithMarketDataDto: [], + tagsDto: [], + user: { + id: 'test-user-id', + settings: { settings: { baseCurrency: 'USD' } } + } as any + }); + + expect(activities).toHaveLength(1); + expect(activities[0].valueInBaseCurrency).toBe( + expectedValueInBaseCurrency + ); + expect(activities[0].value).toBe(15); + expect(exchangeRateDataServiceMock.toCurrencyAtDate).toHaveBeenCalledWith( + 15, + 'USD', + 'USD', + expect.any(Date) + ); + }); + + it('should convert valueInBaseCurrency using the correct currencies', async () => { + exchangeRateDataServiceMock.toCurrencyAtDate.mockResolvedValue(1350.5); + + dataProviderServiceMock.getAssetProfiles.mockResolvedValue({ + MSFT: { + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Corporation', + symbol: 'MSFT' + } + }); + + const activities = await importService.import({ + isDryRun: true, + maxActivitiesToImport: Number.MAX_SAFE_INTEGER, + accountsWithBalancesDto: [], + activitiesDto: [ + { + currency: 'USD', + dataSource: 'YAHOO', + date: '2024-01-15T00:00:00.000Z', + fee: 19, + quantity: 5, + symbol: 'MSFT', + type: 'BUY', + unitPrice: 298.58 + } as any + ], + assetProfilesWithMarketDataDto: [], + tagsDto: [], + user: { + id: 'test-user-id', + settings: { settings: { baseCurrency: 'EUR' } } + } as any + }); + + expect(activities).toHaveLength(1); + expect(activities[0].valueInBaseCurrency).toBe(1350.5); + expect(activities[0].value).toBeCloseTo(1492.9); + expect(exchangeRateDataServiceMock.toCurrencyAtDate).toHaveBeenCalledWith( + expect.closeTo(1492.9), + 'USD', + 'EUR', + expect.any(Date) + ); + }); + + it('should fall back to asset profile currency when activity currency is not set', async () => { + exchangeRateDataServiceMock.toCurrencyAtDate.mockResolvedValue(450); + + dataProviderServiceMock.getAssetProfiles.mockResolvedValue({ + AAPL: { + currency: 'EUR', + dataSource: 'YAHOO', + name: 'Apple Inc.', + symbol: 'AAPL' + } + }); + + const activities = await importService.import({ + isDryRun: true, + maxActivitiesToImport: Number.MAX_SAFE_INTEGER, + accountsWithBalancesDto: [], + activitiesDto: [ + { + currency: undefined, + dataSource: 'YAHOO', + date: '2024-06-01T00:00:00.000Z', + fee: 0, + quantity: 2, + symbol: 'AAPL', + type: 'BUY', + unitPrice: 250 + } as any + ], + assetProfilesWithMarketDataDto: [], + tagsDto: [], + user: { + id: 'test-user-id', + settings: { settings: { baseCurrency: 'CHF' } } + } as any + }); + + expect(activities).toHaveLength(1); + expect(activities[0].valueInBaseCurrency).toBe(450); + + // Should fall back to asset profile currency (EUR) when activity currency is undefined + expect(exchangeRateDataServiceMock.toCurrencyAtDate).toHaveBeenCalledWith( + 500, + 'EUR', + 'CHF', + expect.any(Date) + ); + }); + }); +}); diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 7e8e333b9..a787927b5 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -5,6 +5,7 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.servic import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; @@ -48,6 +49,7 @@ export class ImportService { private readonly configurationService: ConfigurationService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, + private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, private readonly orderService: OrderService, private readonly platformService: PlatformService, @@ -590,10 +592,18 @@ export class ImportService { const value = new Big(quantity).mul(unitPrice).toNumber(); + const valueInBaseCurrency = this.exchangeRateDataService.toCurrencyAtDate( + value, + currency ?? assetProfile.currency, + userCurrency, + date + ); + activities.push({ ...order, error, value, + valueInBaseCurrency: await valueInBaseCurrency, // @ts-ignore SymbolProfile: assetProfile });