diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts index 0cdca8110..4bdb84ea7 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts @@ -48,16 +48,7 @@ export class GhostfolioController { public async getAssetProfile( @Param('symbol') symbol: string ): Promise { - const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); - - if ( - this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests - ) { - throw new HttpException( - getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), - StatusCodes.TOO_MANY_REQUESTS - ); - } + await this.validateDailyRequestLimit(); try { const assetProfile = await this.ghostfolioService.getAssetProfile({ @@ -92,16 +83,7 @@ export class GhostfolioController { @Param('symbol') symbol: string, @Query() { from, granularity, to }: GetDividendsDto ): Promise { - const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); - - if ( - this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests - ) { - throw new HttpException( - getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), - StatusCodes.TOO_MANY_REQUESTS - ); - } + await this.validateDailyRequestLimit(); try { const dividends = await this.ghostfolioService.getDividends({ @@ -132,16 +114,7 @@ export class GhostfolioController { @Param('symbol') symbol: string, @Query() { from, granularity, to }: GetHistoricalDto ): Promise { - const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); - - if ( - this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests - ) { - throw new HttpException( - getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), - StatusCodes.TOO_MANY_REQUESTS - ); - } + await this.validateDailyRequestLimit(); try { const historicalData = await this.ghostfolioService.getHistorical({ @@ -173,16 +146,7 @@ export class GhostfolioController { @Query('query') query = '' ): Promise { const includeIndices = includeIndicesParam === 'true'; - const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); - - if ( - this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests - ) { - throw new HttpException( - getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), - StatusCodes.TOO_MANY_REQUESTS - ); - } + await this.validateDailyRequestLimit(); try { const result = await this.ghostfolioService.lookup({ @@ -212,16 +176,7 @@ export class GhostfolioController { @Query('includeHistoricalData', new ParseIntPipe({ optional: true })) includeHistoricalData = 0 ): Promise { - const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); - - if ( - this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests - ) { - throw new HttpException( - getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), - StatusCodes.TOO_MANY_REQUESTS - ); - } + await this.validateDailyRequestLimit(); try { const marketDataOfMarkets = @@ -249,16 +204,7 @@ export class GhostfolioController { public async getQuotes( @Query() { symbols }: GetQuotesDto ): Promise { - const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); - - if ( - this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests - ) { - throw new HttpException( - getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), - StatusCodes.TOO_MANY_REQUESTS - ); - } + await this.validateDailyRequestLimit(); try { const quotes = await this.ghostfolioService.getQuotes({ @@ -285,4 +231,17 @@ export class GhostfolioController { public async getStatus(): Promise { return this.ghostfolioService.getStatus({ user: this.request.user }); } + + private async validateDailyRequestLimit() { + if ( + await this.ghostfolioService.isDailyRequestLimitExceeded({ + user: this.request.user + }) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + } } diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts index addc5d879..ec0eeeb86 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts @@ -13,10 +13,12 @@ import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_SETUP_PERIOD, + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_SETUP_PERIOD_MAX_REQUESTS_FACTOR, DEFAULT_CURRENCY, - DERIVED_CURRENCIES + DERIVED_CURRENCIES, + PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; -import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; import { getAssetProfileIdentifier, isValidSearchQuery @@ -37,6 +39,7 @@ import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; import { Big } from 'big.js'; +import { addMilliseconds, isBefore } from 'date-fns'; import { isEmpty } from 'lodash'; @Injectable() @@ -232,15 +235,6 @@ export class GhostfolioService { } } - public async getMaxDailyRequests() { - return parseInt( - (await this.propertyService.getByKey( - PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS - )) || '0', - 10 - ); - } - public async getQuotes({ requestTimeout, symbols }: GetQuotesParams) { const results: QuotesResponse = { quotes: {} }; @@ -321,9 +315,17 @@ export class GhostfolioService { } public async getStatus({ user }: { user: UserWithSettings }) { + const dailyRequestsMax = await this.getMaxDailyRequests(); + return { - dailyRequests: user.dataProviderGhostfolioDailyRequests, - dailyRequestsMax: await this.getMaxDailyRequests(), + dailyRequestsMax, + // Cap the reported requests, as they can exceed the reported limit + // within the setup period + dailyRequests: Math.min( + dailyRequestsMax, + user.dataProviderGhostfolioDailyRequests + ), + isWithinSetupPeriod: this.isWithinSetupPeriod({ user }), subscription: user.subscription }; } @@ -341,6 +343,28 @@ export class GhostfolioService { }); } + public async isDailyRequestLimitExceeded({ + user + }: { + user: UserWithSettings; + }) { + const maxDailyRequests = await this.getMaxDailyRequests(); + + if (user.dataProviderGhostfolioDailyRequests < maxDailyRequests) { + return false; + } + + if (this.isWithinSetupPeriod({ user })) { + return ( + user.dataProviderGhostfolioDailyRequests >= + maxDailyRequests * + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_SETUP_PERIOD_MAX_REQUESTS_FACTOR + ); + } + + return true; + } + public async lookup({ includeIndices = false, query @@ -420,4 +444,29 @@ export class GhostfolioService { return this.dataProviderService.getDataProvider(DataSource[dataSource]); }); } + + private async getMaxDailyRequests() { + return parseInt( + (await this.propertyService.getByKey( + PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS + )) || '0', + 10 + ); + } + + private isWithinSetupPeriod({ user }: { user: UserWithSettings }) { + const subscribedAt = user.subscription?.subscribedAt; + + if (!subscribedAt) { + return false; + } + + return isBefore( + new Date(), + addMilliseconds( + subscribedAt, + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_SETUP_PERIOD + ) + ); + } } diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index 1dba93d47..d7caf4559 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -192,6 +192,10 @@ export class SubscriptionService { return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b; }); + const { createdAt: subscribedAt } = subscriptions.reduce((a, b) => { + return new Date(a.createdAt) < new Date(b.createdAt) ? a : b; + }); + let offerKey: SubscriptionOfferKey = price ? 'renewal' : 'default'; if (isBefore(createdAt, parseDate('2023-01-01'))) { @@ -208,6 +212,7 @@ export class SubscriptionService { return { offer, + subscribedAt, expiresAt: isBefore(new Date(), expiresAt) ? expiresAt : undefined, type: isBefore(new Date(), expiresAt) ? SubscriptionType.Premium diff --git a/apps/client/src/app/components/admin-settings/admin-settings.component.html b/apps/client/src/app/components/admin-settings/admin-settings.component.html index b3ad389d6..607d884bd 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.component.html +++ b/apps/client/src/app/components/admin-settings/admin-settings.component.html @@ -147,21 +147,26 @@ isGhostfolioDataProvider(element) && isGhostfolioApiKeyValid === true ) { - - - {{ ghostfolioApiStatus.dailyRequests }} - of - {{ ghostfolioApiStatus.dailyRequestsMax }} - daily requests - +
+ + + {{ ghostfolioApiStatus.dailyRequests }} + of + {{ ghostfolioApiStatus.dailyRequestsMax }} + daily requests + +
} diff --git a/apps/client/src/app/components/admin-settings/admin-settings.component.ts b/apps/client/src/app/components/admin-settings/admin-settings.component.ts index d7333be2a..3ee3771fd 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.component.ts +++ b/apps/client/src/app/components/admin-settings/admin-settings.component.ts @@ -36,6 +36,7 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSort, MatSortModule } from '@angular/material/sort'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; @@ -61,6 +62,7 @@ import { catchError, filter, of } from 'rxjs'; MatProgressBarModule, MatSortModule, MatTableModule, + MatTooltipModule, NgxSkeletonLoaderModule, RouterModule ], @@ -81,6 +83,7 @@ export class GfAdminSettingsComponent implements OnInit { 'actions' ]; public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse; + public readonly ghostfolioApiStatusTooltip = $localize`Additional requests are granted while you are setting up your instance`; public hasGhostfolioApiKey: boolean; public isGhostfolioApiKeyValid: boolean; public isLoading = false; diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index f3ef2c30e..0adae128b 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -67,6 +67,9 @@ export const DATA_GATHERING_QUEUE_PRIORITY_MEDIUM = Math.round( DATA_GATHERING_QUEUE_PRIORITY_LOW / 2 ); +export const DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_SETUP_PERIOD = ms('2 weeks'); +export const DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_SETUP_PERIOD_MAX_REQUESTS_FACTOR = 2; + /** * The named date ranges, complemented by the calendar years like '2024', * '2023', '2022', etc. diff --git a/libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-status-response.interface.ts b/libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-status-response.interface.ts index 9330adaa7..2bdc1ff73 100644 --- a/libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-status-response.interface.ts +++ b/libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-status-response.interface.ts @@ -3,5 +3,6 @@ import { UserWithSettings } from '@ghostfolio/common/types'; export interface DataProviderGhostfolioStatusResponse { dailyRequests: number; dailyRequestsMax: number; + isWithinSetupPeriod: boolean; subscription: UserWithSettings['subscription']; } diff --git a/libs/common/src/lib/types/user-with-settings.type.ts b/libs/common/src/lib/types/user-with-settings.type.ts index 37b2b83e9..3773766b8 100644 --- a/libs/common/src/lib/types/user-with-settings.type.ts +++ b/libs/common/src/lib/types/user-with-settings.type.ts @@ -13,6 +13,7 @@ export type UserWithSettings = User & { subscription?: { expiresAt?: Date; offer: SubscriptionOffer; + subscribedAt?: Date; type: SubscriptionType; }; };