From 2364489ee4dd9b74df2379858d25f4a49a96852a Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:59:37 +0200 Subject: [PATCH] Feature/data source in MCP tools (#8019) * Extend tools with data source * Update changelog --- CHANGELOG.md | 1 + .../src/app/endpoints/mcp/mcp.schemas.spec.ts | 89 ++++++++ apps/api/src/app/endpoints/mcp/mcp.schemas.ts | 24 ++- .../src/app/endpoints/mcp/mcp.service.spec.ts | 114 +++++++++- apps/api/src/app/endpoints/mcp/mcp.service.ts | 31 +-- .../api/src/helper/data-source.helper.spec.ts | 123 +++++++++++ apps/api/src/helper/data-source.helper.ts | 65 ++++++ ...orm-data-source-in-response.interceptor.ts | 5 +- .../data-source-table-context.interface.ts | 5 + .../holdings-table-context.interface.ts | 4 +- .../portfolio-table/portfolio-table.module.ts | 9 +- .../portfolio-table.service.spec.ts | 196 +++++++++++++++++- .../portfolio-table.service.ts | 142 +++++++++---- 13 files changed, 720 insertions(+), 88 deletions(-) create mode 100644 apps/api/src/helper/data-source.helper.spec.ts create mode 100644 apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 959f6f09ef..d64f3d12f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Improved the label of the cash positions in the holdings charts and table +- Extended the tools to get the activities, the portfolio and the watchlist in the server of the Model Context Protocol (MCP) to include the data source (experimental) - Upgraded `@openrouter/ai-sdk-provider` from version `3.0.0` to `3.1.0` - Upgraded `ai` from version `7.0.37` to `7.0.114` - Upgraded `dotenv` from version `17.4.2` to `18.0.3` diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts index dbc5bc698c..e4893c2a7b 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -1,3 +1,4 @@ +import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { COMMENT_MAXIMUM_LENGTH, DEFAULT_DATE_RANGE, @@ -18,6 +19,8 @@ import { } from './mcp.schemas'; import { createActivity } from './mcp.test-utils'; +const UNKNOWN_ENCODED_DATA_SOURCE = 'ffffffff'; + describe('GET_ACCOUNTS_PARAMETERS', () => { it('Refuses an empty symbol of the holding', () => { expect( @@ -34,6 +37,40 @@ describe('GET_ACCOUNTS_PARAMETERS', () => { }).success ).toBe(false); }); + + it('Accepts an encoded data source of the holding', () => { + expect( + GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }).success + ).toBe(true); + }); + + it('Refuses an unknown encoded data source of the holding', () => { + expect( + GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' } + }).success + ).toBe(false); + }); + + it('Gives the valid data sources in the error of an unknown data source of the holding', () => { + for (const dataSource of ['yahoo', 1]) { + const { error } = GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { dataSource, symbol: 'AAPL' } + }); + + expect(error.issues).toEqual([ + expect.objectContaining({ + message: expect.stringContaining(`|${DataSource.YAHOO}`), + path: ['holding', 'dataSource'] + }) + ]); + } + }); }); describe('GET_ACTIVITIES_PARAMETERS', () => { @@ -52,6 +89,25 @@ describe('GET_ACTIVITIES_PARAMETERS', () => { }).success ).toBe(false); }); + + it('Accepts an encoded data source of the holding', () => { + expect( + GET_ACTIVITIES_PARAMETERS.safeParse({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }).success + ).toBe(true); + }); + + it('Refuses an unknown encoded data source of the holding', () => { + expect( + GET_ACTIVITIES_PARAMETERS.safeParse({ + holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' } + }).success + ).toBe(false); + }); }); describe('GET_PERFORMANCE_PARAMETERS', () => { @@ -86,6 +142,25 @@ describe('GET_PERFORMANCE_PARAMETERS', () => { }).success ).toBe(false); }); + + it('Accepts an encoded data source of the holding', () => { + expect( + GET_PERFORMANCE_PARAMETERS.safeParse({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }).success + ).toBe(true); + }); + + it('Refuses an unknown encoded data source of the holding', () => { + expect( + GET_PERFORMANCE_PARAMETERS.safeParse({ + holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' } + }).success + ).toBe(false); + }); }); describe('IMPORT_ACTIVITIES_PARAMETERS', () => { @@ -125,6 +200,20 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => { expect(parse([createActivity({ currency: 'USD' })])).toBe(true); }); + it('Accepts an encoded data source', () => { + expect( + parse([ + createActivity({ dataSource: encodeDataSource(DataSource.YAHOO) }) + ]) + ).toBe(true); + }); + + it('Refuses an unknown encoded data source', () => { + expect( + parse([createActivity({ dataSource: UNKNOWN_ENCODED_DATA_SOURCE })]) + ).toBe(false); + }); + it('Refuses a date at or before the epoch', () => { expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false); }); diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts index ce66699e91..1b200e0119 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts @@ -1,4 +1,5 @@ import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; +import { isValidEncodedDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { COMMENT_MAXIMUM_LENGTH, DATE_RANGES, @@ -17,6 +18,20 @@ import { import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { z } from 'zod'; +const DATA_SOURCE_PARAMETER_ERROR = `Invalid option: expected one of ${Object.values(DataSource).join('|')} or a data source as given by a tool`; + +const DATA_SOURCE_PARAMETER = z + .union( + [ + z.enum(DataSource), + z.string().refine(isValidEncodedDataSource, { + error: DATA_SOURCE_PARAMETER_ERROR + }) + ], + { error: DATA_SOURCE_PARAMETER_ERROR } + ) + .describe('The data source of the asset profile'); + const SYMBOL_PARAMETER = z .string() .trim() @@ -25,9 +40,7 @@ const SYMBOL_PARAMETER = z .describe('The symbol of the asset profile'); const HOLDING_PARAMETER = z.object({ - dataSource: z - .enum(DataSource) - .describe('The data source of the asset profile'), + dataSource: DATA_SOURCE_PARAMETER, symbol: SYMBOL_PARAMETER }); @@ -137,10 +150,7 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ .describe( 'The currency of the fee and of the unit price, as an ISO 4217 code in upper case' ), - dataSource: z - .enum(DataSource) - .optional() - .describe('The data source of the asset profile'), + dataSource: DATA_SOURCE_PARAMETER.optional(), date: z .string() .refine(isValidDateAfter1970) diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts index f4841896bd..a013341179 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -2,6 +2,7 @@ import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import- import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; @@ -128,6 +129,22 @@ describe('McpService', () => { ); }); + it('Decodes an encoded data source', async () => { + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.getAccounts({ + userId, + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + it('Keeps the data source if the subscription is not enabled', async () => { configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.ENABLE_FEATURE_SUBSCRIPTION = false; @@ -192,6 +209,21 @@ describe('McpService', () => { ); }); + it('Decodes an encoded data source', async () => { + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await getActivities({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + it('Keeps the data source if the subscription is not enabled', async () => { configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.ENABLE_FEATURE_SUBSCRIPTION = false; @@ -275,6 +307,23 @@ describe('McpService', () => { ); }); + it('Decodes an encoded data source', async () => { + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.getPerformance({ + userId, + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + }, + range: 'max' + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + it('Keeps the data source if the subscription is not enabled', async () => { configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.ENABLE_FEATURE_SUBSCRIPTION = false; @@ -304,14 +353,15 @@ describe('McpService', () => { }); describe('getPortfolio', () => { - it('Gives the table of the holdings in the default language', async () => { + it('Gives the table of the holdings with the data source in the default language', async () => { expect(await mcpService.getPortfolio({ userId })).toEqual({ content: [{ text: '## Holdings', type: 'text' }] }); expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({ userId, - languageCode: DEFAULT_LANGUAGE_CODE + languageCode: DEFAULT_LANGUAGE_CODE, + withDataSource: true }); }); }); @@ -342,9 +392,6 @@ describe('McpService', () => { it('Gives the import-ready asset profiles available to the user', async () => { const user = setupUser([permissions.createActivity]); - configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; - configuration.ENABLE_FEATURE_SUBSCRIPTION = true; - jest.spyOn(symbolService, 'lookup').mockResolvedValue({ items: [ { @@ -383,12 +430,46 @@ describe('McpService', () => { assetClass: AssetClass.EQUITY, assetSubClass: AssetSubClass.STOCK, currency: 'USD', - dataSource: DataSource.GHOSTFOLIO, + dataSource: DataSource.YAHOO, + name: 'Apple Inc.', + symbol: 'AAPL' + } + ] + }); + }); + + it('Encodes the data source if the subscription is enabled', async () => { + setupUser([permissions.createActivity]); + + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + jest.spyOn(symbolService, 'lookup').mockResolvedValue({ + items: [ + { + assetClass: AssetClass.EQUITY, + assetSubClass: AssetSubClass.STOCK, + currency: 'USD', + dataProviderInfo: { isPremium: false }, + dataSource: DataSource.YAHOO, name: 'Apple Inc.', symbol: 'AAPL' } ] }); + + const result = await mcpService.searchAssetProfiles({ + query: 'Apple', + userId + }); + + expect(JSON.parse(result.content[0].text)).toEqual({ + assetProfiles: [ + expect.objectContaining({ + dataSource: encodeDataSource(DataSource.YAHOO) + }) + ] + }); }); }); @@ -448,6 +529,27 @@ describe('McpService', () => { ); }); + it('Decodes an encoded data source', async () => { + setupUser([permissions.createActivity]); + + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.importActivities({ + userId, + activities: [ + createActivity({ dataSource: encodeDataSource(DataSource.YAHOO) }) + ] + }); + + expect(importService.import).toHaveBeenCalledWith( + expect.objectContaining({ + activitiesDto: [ + expect.objectContaining({ dataSource: DataSource.YAHOO }) + ] + }) + ); + }); + it('Keeps the data source if the subscription is not enabled', async () => { setupUser([permissions.createActivity]); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.ts b/apps/api/src/app/endpoints/mcp/mcp.service.ts index 6a989dc856..70be380060 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.ts @@ -2,8 +2,8 @@ import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { - getMaskedGhostfolioDataSource, - getUnmaskedGhostfolioDataSource + transformDataSourceInRequest, + transformDataSourceInResponse } from '@ghostfolio/api/helper/data-source.helper'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -121,7 +121,8 @@ export class McpService { public async getPortfolio({ userId }: { userId: string }) { const table = await this.portfolioTableService.getHoldingsTable({ userId, - languageCode: DEFAULT_LANGUAGE_CODE + languageCode: DEFAULT_LANGUAGE_CODE, + withDataSource: true }); return this.getTextResult(table); @@ -144,13 +145,11 @@ export class McpService { permission: permissions.createActivity }); - const ghostfolioDataSources = this.getGhostfolioDataSources(); - const activitiesDto = activities.map((activity) => { return { ...activity, - dataSource: getUnmaskedGhostfolioDataSource({ - ghostfolioDataSources, + dataSource: transformDataSourceInRequest({ + configurationService: this.configurationService, dataSource: activity.dataSource }) }; @@ -188,8 +187,6 @@ export class McpService { const { items } = await this.symbolService.lookup({ query, user }); - const ghostfolioDataSources = this.getGhostfolioDataSources(); - const assetProfiles = items.flatMap( ({ assetClass, @@ -211,9 +208,9 @@ export class McpService { currency, name, symbol, - dataSource: getMaskedGhostfolioDataSource({ + dataSource: transformDataSourceInResponse({ dataSource, - ghostfolioDataSources + configurationService: this.configurationService }) } ]; @@ -223,19 +220,13 @@ export class McpService { return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2)); } - private getGhostfolioDataSources() { - return this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') - ? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') - : []; - } - private getHoldingFilterParameters({ holding }: Pick, 'holding'>) { return { - filterByDataSource: getUnmaskedGhostfolioDataSource({ - dataSource: holding?.dataSource, - ghostfolioDataSources: this.getGhostfolioDataSources() + filterByDataSource: transformDataSourceInRequest({ + configurationService: this.configurationService, + dataSource: holding?.dataSource }), filterBySymbol: holding?.symbol }; diff --git a/apps/api/src/helper/data-source.helper.spec.ts b/apps/api/src/helper/data-source.helper.spec.ts new file mode 100644 index 0000000000..02d979441e --- /dev/null +++ b/apps/api/src/helper/data-source.helper.spec.ts @@ -0,0 +1,123 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { DataSource } from '@prisma/client'; + +import { + encodeDataSource, + isValidEncodedDataSource, + transformDataSourceInRequest, + transformDataSourceInResponse +} from './data-source.helper'; + +function createConfigurationService({ + isSubscriptionEnabled +}: { + isSubscriptionEnabled: boolean; +}) { + const configuration: Record = { + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [DataSource.YAHOO], + ENABLE_FEATURE_SUBSCRIPTION: isSubscriptionEnabled + }; + + return { + get: jest.fn((key: string) => { + return configuration[key]; + }) + } as unknown as ConfigurationService; +} + +describe('DataSourceHelper', () => { + describe('isValidEncodedDataSource', () => { + it('Accepts an encoded data source', () => { + expect(isValidEncodedDataSource(encodeDataSource(DataSource.YAHOO))).toBe( + true + ); + }); + + it('Refuses a data source which is not encoded', () => { + expect(isValidEncodedDataSource(DataSource.YAHOO)).toBe(false); + }); + + it('Refuses an unknown encoded data source', () => { + expect(isValidEncodedDataSource('ffffffff')).toBe(false); + }); + }); + + describe('transformDataSourceInRequest', () => { + const configurationService = createConfigurationService({ + isSubscriptionEnabled: true + }); + + it('Decodes an encoded data source', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: encodeDataSource(DataSource.COINGECKO) + }) + ).toBe(DataSource.COINGECKO); + }); + + it('Resolves the mask of the data source of the Ghostfolio data provider', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: DataSource.GHOSTFOLIO + }) + ).toBe(DataSource.YAHOO); + }); + + it('Keeps a data source which is not encoded', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: DataSource.COINGECKO + }) + ).toBe(DataSource.COINGECKO); + }); + + it('Keeps an absent data source', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: undefined + }) + ).toBeUndefined(); + }); + }); + + describe('transformDataSourceInResponse', () => { + it('Encodes the data source if the subscription is enabled', () => { + expect( + transformDataSourceInResponse({ + configurationService: createConfigurationService({ + isSubscriptionEnabled: true + }), + dataSource: DataSource.YAHOO + }) + ).toBe(encodeDataSource(DataSource.YAHOO)); + }); + + it('Keeps the data sources GHOSTFOLIO and MANUAL if the subscription is enabled', () => { + const configurationService = createConfigurationService({ + isSubscriptionEnabled: true + }); + + for (const dataSource of [DataSource.GHOSTFOLIO, DataSource.MANUAL]) { + expect( + transformDataSourceInResponse({ configurationService, dataSource }) + ).toBe(dataSource); + } + }); + + it('Keeps the data source if the subscription is not enabled', () => { + expect( + transformDataSourceInResponse({ + configurationService: createConfigurationService({ + isSubscriptionEnabled: false + }), + dataSource: DataSource.YAHOO + }) + ).toBe(DataSource.YAHOO); + }); + }); +}); diff --git a/apps/api/src/helper/data-source.helper.ts b/apps/api/src/helper/data-source.helper.ts index fff8eaac1d..79802883e8 100644 --- a/apps/api/src/helper/data-source.helper.ts +++ b/apps/api/src/helper/data-source.helper.ts @@ -1,6 +1,13 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + import { DataSource } from '@prisma/client'; import { createHash } from 'node:crypto'; +const DATA_SOURCES_WITHOUT_ENCODING: DataSource[] = [ + DataSource.GHOSTFOLIO, + DataSource.MANUAL +]; + const encodedDataSourceByDataSource = new Map( Object.values(DataSource).map((dataSource) => { return [dataSource, hashDataSource(dataSource)]; @@ -30,6 +37,16 @@ function deprecatedHashDataSource(dataSource: DataSource) { return Buffer.from(dataSource, 'utf-8').toString('hex'); } +function getGhostfolioDataSources({ + configurationService +}: { + configurationService: ConfigurationService; +}) { + return configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') + ? configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') + : []; +} + function hashDataSource(dataSource: DataSource) { return createHash('sha256').update(dataSource).digest('hex').slice(0, 8); } @@ -77,3 +94,51 @@ export function getUnmaskedGhostfolioDataSource({ ? (ghostfolioDataSources[0] as DataSource) : dataSource; } + +export function isDataSourceEncodedInResponse(dataSource: DataSource) { + return !DATA_SOURCES_WITHOUT_ENCODING.includes(dataSource); +} + +export function isValidEncodedDataSource(encodedDataSource: string) { + return dataSourceByEncodedDataSource.has(encodedDataSource); +} + +/** + * Gives the data source of a request without a transformation: an encoded data + * source is decoded, and the mask of the Ghostfolio data provider is resolved + * (see the TransformDataSourceInRequestInterceptor) + */ +export function transformDataSourceInRequest({ + configurationService, + dataSource +}: { + configurationService: ConfigurationService; + dataSource?: string; +}) { + if (Object.hasOwn(DataSource, dataSource)) { + return getUnmaskedGhostfolioDataSource({ + dataSource: dataSource as DataSource, + ghostfolioDataSources: getGhostfolioDataSources({ configurationService }) + }); + } + + return decodeDataSource(dataSource) as DataSource; +} + +/** + * Gives the data source as a response gives it to a user who is not an admin: + * encoded if the subscription is enabled, except the data sources GHOSTFOLIO + * and MANUAL (see the TransformDataSourceInResponseInterceptor) + */ +export function transformDataSourceInResponse({ + configurationService, + dataSource +}: { + configurationService: ConfigurationService; + dataSource: DataSource; +}) { + return configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + isDataSourceEncodedInResponse(dataSource) + ? encodeDataSource(dataSource) + : dataSource; +} diff --git a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts index 339b91a2f7..968159cbac 100644 --- a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts @@ -1,6 +1,7 @@ import { encodeDataSource, - getMaskedGhostfolioDataSource + getMaskedGhostfolioDataSource, + isDataSourceEncodedInResponse } from '@ghostfolio/api/helper/data-source.helper'; import { redactPaths } from '@ghostfolio/api/helper/object.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -30,7 +31,7 @@ export class TransformDataSourceInResponseInterceptor< if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { this.encodedDataSourceMap = Object.keys(DataSource).reduce( (encodedDataSourceMap, dataSource) => { - if (!['GHOSTFOLIO', 'MANUAL'].includes(dataSource)) { + if (isDataSourceEncodedInResponse(DataSource[dataSource])) { encodedDataSourceMap[dataSource] = encodeDataSource( DataSource[dataSource] ); diff --git a/apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts b/apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts new file mode 100644 index 0000000000..927f32cda0 --- /dev/null +++ b/apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts @@ -0,0 +1,5 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +export interface DataSourceTableContext { + configurationService: ConfigurationService; +} diff --git a/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts b/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts index 4364b492e7..771cd10404 100644 --- a/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts +++ b/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts @@ -1,6 +1,8 @@ import { AssetClass, AssetSubClass } from '@prisma/client'; -export interface HoldingsTableContext { +import { DataSourceTableContext } from './data-source-table-context.interface'; + +export interface HoldingsTableContext extends DataSourceTableContext { assetClassTranslations: Record; assetSubClassTranslations: Record; } diff --git a/apps/api/src/services/portfolio-table/portfolio-table.module.ts b/apps/api/src/services/portfolio-table/portfolio-table.module.ts index 0995dc9980..01d0ebc64d 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.module.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.module.ts @@ -1,6 +1,7 @@ import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { WatchlistModule } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; import { Module } from '@nestjs/common'; @@ -9,7 +10,13 @@ import { PortfolioTableService } from './portfolio-table.service'; @Module({ exports: [PortfolioTableService], - imports: [ActivitiesModule, I18nModule, PortfolioModule, WatchlistModule], + imports: [ + ActivitiesModule, + ConfigurationModule, + I18nModule, + PortfolioModule, + WatchlistModule + ], providers: [PortfolioTableService] }) export class PortfolioTableModule {} diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts index ce92747c40..3cfb9fe856 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts @@ -1,12 +1,16 @@ +import type { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import type { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service'; import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface'; +import type { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { DEFAULT_LANGUAGE_CODE, TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { + Activity, HistoricalDataItem, PortfolioPerformanceResponse, PortfolioPosition, @@ -60,6 +64,8 @@ jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => { }; }); +const SUBSCRIPTION_CONFIGURATION = { ENABLE_FEATURE_SUBSCRIPTION: true }; + function createAccount({ id = 'account-a-id', isExcluded = false, @@ -82,15 +88,39 @@ function createAccount({ } as unknown as AccountWithValue; } +function createActivity({ + dataSource = DataSource.YAHOO, + symbol = 'AAPL' +}: { + dataSource?: DataSource; + symbol?: string; +} = {}) { + return { + account: { name: 'Account A' }, + assetProfile: { + dataSource, + symbol, + currency: 'CHF', + name: `Name of ${symbol}` + }, + currency: 'CHF', + date: new Date('2024-01-01'), + type: 'BUY', + unitPrice: 100 + } as unknown as Activity; +} + function createHolding({ allocationInPercentage = 0.75, assetClass = AssetClass.EQUITY, assetSubClass = AssetSubClass.STOCK, + dataSource = DataSource.YAHOO, symbol = 'AAPL' }: { allocationInPercentage?: number; assetClass?: AssetClass; assetSubClass?: AssetSubClass; + dataSource?: DataSource; symbol?: string; } = {}) { return { @@ -99,6 +129,7 @@ function createHolding({ assetProfile: { assetClass, assetSubClass, + dataSource, symbol, currency: 'CHF', name: `Name of ${symbol}` @@ -132,18 +163,20 @@ function createPerformance({ } function createWatchlistItem({ + dataSource = DataSource.YAHOO, name = 'Name of AAPL', performancePercent = -0.25, symbol = 'AAPL' }: { + dataSource?: DataSource; name?: string; performancePercent?: number; symbol?: string; } = {}): WatchlistResponse['watchlist'][number] { return { + dataSource, name, symbol, - dataSource: DataSource.YAHOO, marketCondition: 'BEAR_MARKET', performances: { allTimeHigh: { @@ -158,17 +191,33 @@ function createWatchlistItem({ function createPortfolioTableService({ accounts = [], + activities = [], chart = [{ date: '2024-01-01' }], + configuration = { ENABLE_FEATURE_SUBSCRIPTION: false }, holdings = [], performance = createPerformance(), watchlist = [] }: { accounts?: AccountWithValue[]; + activities?: Activity[]; chart?: HistoricalDataItem[]; + configuration?: Record; holdings?: PortfolioPosition[]; performance?: PortfolioPerformanceResponse['performance']; watchlist?: WatchlistResponse['watchlist']; } = {}) { + const activitiesService = { + getActivities: jest + .fn() + .mockResolvedValue({ activities, count: activities.length }) + } as unknown as ActivitiesService; + + const configurationService = { + get: jest.fn((key: string) => { + return configuration[key]; + }) + } as unknown as ConfigurationService; + // The mock gives the identifier of the translation, so that a test can tell // the translation of the asset class from that of the asset sub class const i18nService = { @@ -188,7 +237,8 @@ function createPortfolioTableService({ } as unknown as WatchlistService; return new PortfolioTableService( - null, + activitiesService, + configurationService, i18nService, portfolioService, watchlistService @@ -220,6 +270,7 @@ describe('PortfolioTableService', () => { 'Type', 'Name', 'Symbol', + 'Data Source', 'Currency', 'Unit Price', 'Account' @@ -232,6 +283,7 @@ describe('PortfolioTableService', () => { expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([ 'Name', 'Symbol', + 'Data Source', 'Currency', 'Asset Class', 'Asset Sub Class', @@ -257,6 +309,7 @@ describe('PortfolioTableService', () => { expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([ 'Name', 'Symbol', + 'Data Source', 'Trend 50 Days', 'Trend 200 Days', 'Date of Last All Time High', @@ -327,14 +380,118 @@ describe('PortfolioTableService', () => { expect(result).toContain('No accounts found.'); }); }); + + describe('getActivitiesTable', () => { + it('gives the data source, encoded if the subscription is enabled', async () => { + const result = await createPortfolioTableService({ + activities: [ + createActivity({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }), + createActivity({ + dataSource: DataSource.MANUAL, + symbol: 'GF_GOLD' + }) + ], + configuration: SUBSCRIPTION_CONFIGURATION + }).getActivitiesTable({ + take: 50, + userCurrency: 'CHF', + userId: 'user-id' + }); + + const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => { + return line.startsWith('| 2024-01-01'); + }); + + expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); + expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); + }); + }); + describe('getHoldingsTable', () => { - function getHoldingsTable(holdings: PortfolioPosition[]) { - return createPortfolioTableService({ holdings }).getHoldingsTable({ + function getHoldingsTable( + holdings: PortfolioPosition[], + { + configuration, + withDataSource + }: { + configuration?: Record; + withDataSource?: boolean; + } = {} + ) { + return createPortfolioTableService({ + configuration, + holdings + }).getHoldingsTable({ + withDataSource, languageCode: DEFAULT_LANGUAGE_CODE, userId: 'user-id' }); } + it('gives no data source by default', async () => { + const result = await getHoldingsTable([createHolding()], { + configuration: SUBSCRIPTION_CONFIGURATION + }); + + expect(result).not.toContain('Data Source'); + expect(result).not.toContain(encodeDataSource(DataSource.YAHOO)); + }); + + it('gives the data source, encoded if the subscription is enabled', async () => { + const result = await getHoldingsTable( + [ + createHolding({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }), + createHolding({ + allocationInPercentage: 0.25, + dataSource: DataSource.MANUAL, + symbol: 'GF_GOLD' + }) + ], + { configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true } + ); + + const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => { + return line.startsWith('| Name of'); + }); + + expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); + expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); + }); + + it('gives the data source unencoded if the subscription is disabled', async () => { + const result = await getHoldingsTable([createHolding()], { + configuration: { ENABLE_FEATURE_SUBSCRIPTION: false }, + withDataSource: true + }); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| Name of AAPL'); + }); + + expect(row).toContain(`| ${DataSource.YAHOO} |`); + }); + + it('gives no data source of a cash position', async () => { + const result = await getHoldingsTable( + [ + createHolding({ + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + dataSource: DataSource.YAHOO, + symbol: 'USD' + }) + ], + { configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true } + ); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| Name of USD'); + }); + + expect(row).toContain('| USD | | CHF |'); + expect(row).not.toContain(encodeDataSource(DataSource.YAHOO)); + }); + it('gives the translation of the asset class and of the asset sub class', async () => { const result = await getHoldingsTable([createHolding()]); @@ -417,12 +574,39 @@ describe('PortfolioTableService', () => { }); describe('getWatchlistTable', () => { - function getWatchlistTable(watchlist: WatchlistResponse['watchlist']) { - return createPortfolioTableService({ watchlist }).getWatchlistTable({ + function getWatchlistTable( + watchlist: WatchlistResponse['watchlist'], + configuration?: Record + ) { + return createPortfolioTableService({ + configuration, + watchlist + }).getWatchlistTable({ userId: 'user-id' }); } + it('gives the data source, encoded if the subscription is enabled', async () => { + const result = await getWatchlistTable( + [ + createWatchlistItem(), + createWatchlistItem({ + dataSource: DataSource.MANUAL, + name: 'Name of GF_GOLD', + symbol: 'GF_GOLD' + }) + ], + SUBSCRIPTION_CONFIGURATION + ); + + const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => { + return line.startsWith('| Name of'); + }); + + expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); + expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); + }); + it('gives the date and the change of the all time high', async () => { const result = await getWatchlistTable([createWatchlistItem()]); diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.ts index a9c4c36121..9e63d133b5 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.ts @@ -1,10 +1,16 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { transformDataSourceInResponse } from '@ghostfolio/api/helper/data-source.helper'; import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface'; import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; -import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + isAccountExcluded, + isCashPosition +} from '@ghostfolio/common/helper'; import { Activity, Filter, @@ -17,12 +23,31 @@ import { Injectable } from '@nestjs/common'; import { AssetClass, AssetSubClass, + DataSource, Type as ActivityType } from '@prisma/client'; import { format } from 'date-fns'; +import { DataSourceTableContext } from './interfaces/data-source-table-context.interface'; import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; +const DATA_SOURCE_COLUMN_NAME = 'Data Source'; + +function getDataSourceColumnDefinition( + getDataSource: (row: T) => DataSource | undefined +): TableColumnDefinition { + return { + getValue: (row, { configurationService }) => { + const dataSource = getDataSource(row); + + return dataSource + ? transformDataSourceInResponse({ configurationService, dataSource }) + : ''; + }, + name: DATA_SOURCE_COLUMN_NAME + }; +} + function getPercentage(value: number) { return `${(value * 100).toFixed(3)}%`; } @@ -83,52 +108,57 @@ export class PortfolioTableService { } ]; - private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition[] = - [ - { - getValue: ({ date }) => { - return format(date, DATE_FORMAT); - }, - name: 'Date' + private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< + Activity, + DataSourceTableContext + >[] = [ + { + getValue: ({ date }) => { + return format(date, DATE_FORMAT); }, - { - getValue: ({ type }) => { - return type; - }, - name: 'Type' + name: 'Date' + }, + { + getValue: ({ type }) => { + return type; }, - { - getValue: ({ assetProfile }) => { - return assetProfile.name ?? ''; - }, - name: 'Name' + name: 'Type' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.name ?? ''; }, - { - getValue: ({ assetProfile }) => { - return assetProfile.symbol; - }, - name: 'Symbol' + name: 'Name' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.symbol; }, - { - getValue: ({ assetProfile, currency }) => { - return currency ?? assetProfile.currency; - }, - name: 'Currency' + name: 'Symbol' + }, + getDataSourceColumnDefinition(({ assetProfile }) => { + return assetProfile.dataSource; + }), + { + getValue: ({ assetProfile, currency }) => { + return currency ?? assetProfile.currency; }, - { - align: 'right', - getValue: ({ unitPrice }) => { - return unitPrice.toString(); - }, - name: 'Unit Price' + name: 'Currency' + }, + { + align: 'right', + getValue: ({ unitPrice }) => { + return unitPrice.toString(); }, - { - getValue: ({ account }) => { - return account?.name ?? ''; - }, - name: 'Account' - } - ]; + name: 'Unit Price' + }, + { + getValue: ({ account }) => { + return account?.name ?? ''; + }, + name: 'Account' + } + ]; private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] = [ @@ -144,6 +174,11 @@ export class PortfolioTableService { }, name: 'Symbol' }, + getDataSourceColumnDefinition(({ assetProfile }) => { + return isCashPosition(assetProfile) + ? undefined + : assetProfile.dataSource; + }), { getValue: ({ assetProfile }) => { return assetProfile.currency; @@ -222,7 +257,8 @@ export class PortfolioTableService { ]; private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< - WatchlistResponse['watchlist'][number] + WatchlistResponse['watchlist'][number], + DataSourceTableContext >[] = [ { getValue: ({ name }) => { @@ -236,6 +272,9 @@ export class PortfolioTableService { }, name: 'Symbol' }, + getDataSourceColumnDefinition(({ dataSource }) => { + return dataSource; + }), { getValue: ({ trend50d }) => { return trend50d; @@ -273,6 +312,7 @@ export class PortfolioTableService { public constructor( private readonly activitiesService: ActivitiesService, + private readonly configurationService: ConfigurationService, private readonly i18nService: I18nService, private readonly portfolioService: PortfolioService, private readonly watchlistService: WatchlistService @@ -399,6 +439,7 @@ export class PortfolioTableService { await getMarkdownTable({ columnDefinitions: PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, + context: { configurationService: this.configurationService }, rows: activities }) ); @@ -410,11 +451,13 @@ export class PortfolioTableService { public async getHoldingsTable({ filters, languageCode, - userId + userId, + withDataSource = false }: { filters?: Filter[]; languageCode: string; userId: string; + withDataSource?: boolean; }) { const { holdings } = await this.portfolioService.getDetails({ filters, @@ -442,8 +485,16 @@ export class PortfolioTableService { '', await getMarkdownTable({ columnDefinitions: - PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, - context: { assetClassTranslations, assetSubClassTranslations }, + PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.filter( + ({ name }) => { + return withDataSource || name !== DATA_SOURCE_COLUMN_NAME; + } + ), + context: { + assetClassTranslations, + assetSubClassTranslations, + configurationService: this.configurationService + }, rows: sortedHoldings }) ].join('\n'); @@ -491,6 +542,7 @@ export class PortfolioTableService { await getMarkdownTable({ columnDefinitions: PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS, + context: { configurationService: this.configurationService }, rows: watchlist }) );