Browse Source

Support asset profiles of premium data provider which user already has

pull/8196/head
Thomas Kaul 1 day ago
parent
commit
8b4ef87174
  1. 1
      apps/api/src/app/activities/activities.controller.ts
  2. 3
      apps/api/src/app/import/import.service.ts
  3. 148
      apps/api/src/services/data-provider/data-provider.service.spec.ts
  4. 59
      apps/api/src/services/data-provider/data-provider.service.ts

1
apps/api/src/app/activities/activities.controller.ts

@ -233,6 +233,7 @@ export class ActivitiesController {
try { try {
await this.dataProviderService.validateActivities({ await this.dataProviderService.validateActivities({
subscription, subscription,
userId,
activitiesDto: [ activitiesDto: [
{ {
currency: data.currency, currency: data.currency,

3
apps/api/src/app/import/import.service.ts

@ -761,7 +761,8 @@ export class ImportService {
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto, assetProfilesWithMarketDataDto,
maxActivitiesToImport, maxActivitiesToImport,
subscription: user.subscription subscription: user.subscription,
userId: user.id
}); });
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({

148
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'
});
});
});
});

59
apps/api/src/services/data-provider/data-provider.service.ts

@ -216,7 +216,8 @@ export class DataProviderService implements OnModuleInit {
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto, assetProfilesWithMarketDataDto,
maxActivitiesToImport, maxActivitiesToImport,
subscription subscription,
userId
}: { }: {
activitiesDto: Pick< activitiesDto: Pick<
Partial<CreateOrderDto>, Partial<CreateOrderDto>,
@ -225,6 +226,7 @@ export class DataProviderService implements OnModuleInit {
assetProfilesWithMarketDataDto?: ImportDataDto['assetProfiles']; assetProfilesWithMarketDataDto?: ImportDataDto['assetProfiles'];
maxActivitiesToImport: number; maxActivitiesToImport: number;
subscription: UserWithSettings['subscription']; subscription: UserWithSettings['subscription'];
userId: string;
}) { }) {
if (activitiesDto?.length > maxActivitiesToImport) { if (activitiesDto?.length > maxActivitiesToImport) {
throw new ImportValidationError( 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({ const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource, dataSource,
symbol 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]) { if (!assetProfiles[assetProfileIdentifier]) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find( const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(assetProfileWithMarketData) => { (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<SymbolProfile>
> {
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({ private hasCurrency({
assetProfileIdentifiers, assetProfileIdentifiers,
currency currency

Loading…
Cancel
Save