diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index 98c3f674bc..84f62395a7 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -233,6 +233,7 @@ export class ActivitiesController { try { await this.dataProviderService.validateActivities({ subscription, + userId, activitiesDto: [ { currency: data.currency, diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 6f036479c7..83d7b45756 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -761,7 +761,8 @@ export class ImportService { activitiesDto, assetProfilesWithMarketDataDto, maxActivitiesToImport, - subscription: user.subscription + subscription: user.subscription, + userId: user.id }); const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ diff --git a/apps/api/src/services/data-provider/data-provider.service.spec.ts b/apps/api/src/services/data-provider/data-provider.service.spec.ts new file mode 100644 index 0000000000..620c8726dc --- /dev/null +++ b/apps/api/src/services/data-provider/data-provider.service.spec.ts @@ -0,0 +1,148 @@ +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { SubscriptionType } from '@ghostfolio/common/enums'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import type { UserWithSettings } from '@ghostfolio/common/types'; + +import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; + +import { DataProviderService } from './data-provider.service'; + +describe('DataProviderService', () => { + describe('validateActivities', () => { + const assetProfileOfUser = { + assetClass: AssetClass.EQUITY, + assetSubClass: AssetSubClass.ETF, + currency: 'USD', + dataSource: DataSource.EOD_HISTORICAL_DATA, + isin: 'IE00BK5BQT80', + name: 'Vanguard FTSE All-World UCITS ETF', + symbol: 'VWRA.LSE' + }; + + const activityOfAssetProfileOfUser = { + currency: 'USD', + dataSource: DataSource.EOD_HISTORICAL_DATA, + symbol: 'VWRA.LSE', + type: 'BUY' as const + }; + + const basicSubscription = { + type: SubscriptionType.Basic + } as UserWithSettings['subscription']; + + const userId = 'user-id'; + + let dataProviderService: DataProviderService; + let eodHistoricalDataService: DataProviderInterface; + let findFirstSymbolProfile: jest.Mock; + + beforeEach(() => { + const configuration = { + DATA_SOURCES: [DataSource.EOD_HISTORICAL_DATA, DataSource.YAHOO], + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [], + ENABLE_FEATURE_SUBSCRIPTION: true + }; + + eodHistoricalDataService = { + getAssetProfile: jest.fn().mockResolvedValue(assetProfileOfUser), + getDataProviderInfo: jest.fn().mockReturnValue({ isPremium: true }), + getName: jest.fn().mockReturnValue(DataSource.EOD_HISTORICAL_DATA) + } as unknown as DataProviderInterface; + + findFirstSymbolProfile = jest.fn().mockResolvedValue(assetProfileOfUser); + + dataProviderService = new DataProviderService( + { + get: (key: keyof typeof configuration) => { + return configuration[key]; + } + } as unknown as ConfigurationService, + [eodHistoricalDataService], + {} as MarketDataService, + { + symbolProfile: { findFirst: findFirstSymbolProfile } + } as unknown as PrismaService, + { + getByKey: jest.fn().mockResolvedValue(undefined) + } as unknown as PropertyService, + {} as RedisCacheService + ); + }); + + it('takes the stored asset profile of a premium data provider without a request to the data provider for the Basic subscription if the user has an activity with it', async () => { + await expect( + dataProviderService.validateActivities({ + userId, + activitiesDto: [activityOfAssetProfileOfUser], + maxActivitiesToImport: 1, + subscription: basicSubscription + }) + ).resolves.toEqual({ + [getAssetProfileIdentifier(assetProfileOfUser)]: assetProfileOfUser + }); + + expect(findFirstSymbolProfile).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + activities: { some: { userId } }, + dataSource: DataSource.EOD_HISTORICAL_DATA, + symbol: 'VWRA.LSE' + } + }) + ); + + expect(eodHistoricalDataService.getAssetProfile).not.toHaveBeenCalled(); + }); + + it('gets the stored asset profile once for several activities with it', async () => { + await dataProviderService.validateActivities({ + userId, + activitiesDto: [ + activityOfAssetProfileOfUser, + { ...activityOfAssetProfileOfUser, type: 'SELL' } + ], + maxActivitiesToImport: 2, + subscription: basicSubscription + }); + + expect(findFirstSymbolProfile).toHaveBeenCalledTimes(1); + }); + + it('throws an error for a premium data provider for the Basic subscription if the user has no activity with the asset profile', async () => { + findFirstSymbolProfile.mockResolvedValue(null); + + await expect( + dataProviderService.validateActivities({ + userId, + activitiesDto: [activityOfAssetProfileOfUser], + maxActivitiesToImport: 1, + subscription: basicSubscription + }) + ).rejects.toThrow( + 'activity.dataSource ("EOD_HISTORICAL_DATA") requires Ghostfolio Premium' + ); + }); + + it('resolves the asset profile of a premium data provider with the data provider for the Premium subscription', async () => { + await dataProviderService.validateActivities({ + userId, + activitiesDto: [activityOfAssetProfileOfUser], + maxActivitiesToImport: 1, + subscription: { + type: SubscriptionType.Premium + } as UserWithSettings['subscription'] + }); + + expect(findFirstSymbolProfile).not.toHaveBeenCalled(); + + expect(eodHistoricalDataService.getAssetProfile).toHaveBeenCalledWith({ + symbol: 'VWRA.LSE' + }); + }); + }); +}); diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 914bb65427..487474133a 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -216,7 +216,8 @@ export class DataProviderService implements OnModuleInit { activitiesDto, assetProfilesWithMarketDataDto, maxActivitiesToImport, - subscription + subscription, + userId }: { activitiesDto: Pick< Partial, @@ -225,6 +226,7 @@ export class DataProviderService implements OnModuleInit { assetProfilesWithMarketDataDto?: ImportDataDto['assetProfiles']; maxActivitiesToImport: number; subscription: UserWithSettings['subscription']; + userId: string; }) { if (activitiesDto?.length > maxActivitiesToImport) { throw new ImportValidationError( @@ -267,17 +269,31 @@ export class DataProviderService implements OnModuleInit { ); } - if (this.isPremiumRequired({ dataSource, subscription })) { - throw new ImportValidationError( - `${activityPath}.dataSource ("${maskedDataSource}") requires Ghostfolio Premium` - ); - } - const assetProfileIdentifier = getAssetProfileIdentifier({ dataSource, symbol }); + if (this.isPremiumRequired({ dataSource, subscription })) { + // The user can add activities to an asset profile of a premium data + // provider which the user already has, for example after the Premium + // subscription has expired. The stored asset profile is taken, so + // that the validation sends no request to the data provider. + const assetProfileOfUser = + assetProfiles[assetProfileIdentifier] ?? + (await this.getAssetProfileOfUser({ dataSource, symbol, userId })); + + if (!assetProfileOfUser) { + throw new ImportValidationError( + `${activityPath}.dataSource ("${maskedDataSource}") requires Ghostfolio Premium` + ); + } + + assetProfiles[assetProfileIdentifier] = assetProfileOfUser; + + continue; + } + if (!assetProfiles[assetProfileIdentifier]) { const assetProfileInImport = assetProfilesWithMarketDataDto?.find( (assetProfileWithMarketData) => { @@ -942,6 +958,35 @@ export class DataProviderService implements OnModuleInit { }; } + /** + * Gives the stored asset profile if the user has an activity with it, with + * the fields which a data provider gives + */ + private async getAssetProfileOfUser({ + dataSource, + symbol, + userId + }: AssetProfileIdentifier & { userId: string }): Promise< + Partial + > { + return this.prismaService.symbolProfile.findFirst({ + select: { + assetClass: true, + assetSubClass: true, + currency: true, + dataSource: true, + isin: true, + name: true, + symbol: true + }, + where: { + dataSource, + symbol, + activities: { some: { userId } } + } + }); + } + private hasCurrency({ assetProfileIdentifiers, currency