Feature/move subscription offer from info to user service (#4533)

* Move subscription offer from info to user service

* Update changelog
This commit is contained in:
Thomas Kaul 2025-04-16 20:57:28 +02:00 committed by GitHub
parent 5072ba09aa
commit 94e53c7d4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 77 additions and 80 deletions

View File

@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Deactivated asset profiles automatically on delisting in the _Yahoo Finance_ service - Deactivated asset profiles automatically on delisting in the _Yahoo Finance_ service
- Optimized the query of the data range functionality (`getRange()`) in the market data service - Optimized the query of the data range functionality (`getRange()`) in the market data service
- Moved the subscription offer from the info to the user service
- Upgraded `Nx` from version `20.7.1` to `20.8.0` - Upgraded `Nx` from version `20.7.1` to `20.8.0`
- Upgraded `prisma` from version `6.5.0` to `6.6.0` - Upgraded `prisma` from version `6.5.0` to `6.6.0`
- Upgraded `storybook` from version `8.4.7` to `8.6.12` - Upgraded `storybook` from version `8.4.7` to `8.6.12`

View File

@ -1,5 +1,6 @@
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
@ -31,6 +32,7 @@ import { InfoService } from './info.service';
PlatformModule, PlatformModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SubscriptionModule,
SymbolProfileModule, SymbolProfileModule,
TransformDataSourceInResponseModule, TransformDataSourceInResponseModule,
UserModule UserModule

View File

@ -1,5 +1,6 @@
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -13,7 +14,6 @@ import {
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS, PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG,
ghostfolioFearAndGreedIndexDataSource ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
@ -21,13 +21,8 @@ import {
encodeDataSource, encodeDataSource,
extractNumberFromString extractNumberFromString
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
InfoItem,
Statistics,
SubscriptionOffer
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
@ -46,6 +41,7 @@ export class InfoService {
private readonly platformService: PlatformService, private readonly platformService: PlatformService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly subscriptionService: SubscriptionService,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -101,7 +97,7 @@ export class InfoService {
isUserSignupEnabled, isUserSignupEnabled,
platforms, platforms,
statistics, statistics,
subscriptionOffers subscriptionOffer
] = await Promise.all([ ] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(), this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(), this.getDemoAuthToken(),
@ -110,7 +106,7 @@ export class InfoService {
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getStatistics(), this.getStatistics(),
this.getSubscriptionOffers() this.subscriptionService.getSubscriptionOffer({ key: 'default' })
]); ]);
if (isUserSignupEnabled) { if (isUserSignupEnabled) {
@ -125,7 +121,7 @@ export class InfoService {
isReadOnlyMode, isReadOnlyMode,
platforms, platforms,
statistics, statistics,
subscriptionOffers, subscriptionOffer,
baseCurrency: DEFAULT_CURRENCY, baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies() currencies: this.exchangeRateDataService.getCurrencies()
}; };
@ -299,19 +295,6 @@ export class InfoService {
return statistics; return statistics;
} }
private async getSubscriptionOffers(): Promise<{
[offer in SubscriptionOfferKey]: SubscriptionOffer;
}> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
return (
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
{}
);
}
private async getUptime(): Promise<number> { private async getUptime(): Promise<number> {
{ {
try { try {

View File

@ -158,26 +158,30 @@ export class SubscriptionService {
} }
} }
public getSubscription({ public async getSubscription({
createdAt, createdAt,
subscriptions subscriptions
}: { }: {
createdAt: UserWithSettings['createdAt']; createdAt: UserWithSettings['createdAt'];
subscriptions: Subscription[]; subscriptions: Subscription[];
}): UserWithSettings['subscription'] { }): Promise<UserWithSettings['subscription']> {
if (subscriptions.length > 0) { if (subscriptions.length > 0) {
const { expiresAt, price } = subscriptions.reduce((a, b) => { const { expiresAt, price } = subscriptions.reduce((a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b; return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
}); });
let offer: SubscriptionOfferKey = price ? 'renewal' : 'default'; let offerKey: SubscriptionOfferKey = price ? 'renewal' : 'default';
if (isBefore(createdAt, parseDate('2023-01-01'))) { if (isBefore(createdAt, parseDate('2023-01-01'))) {
offer = 'renewal-early-bird-2023'; offerKey = 'renewal-early-bird-2023';
} else if (isBefore(createdAt, parseDate('2024-01-01'))) { } else if (isBefore(createdAt, parseDate('2024-01-01'))) {
offer = 'renewal-early-bird-2024'; offerKey = 'renewal-early-bird-2024';
} }
const offer = await this.getSubscriptionOffer({
key: offerKey
});
return { return {
expiresAt, expiresAt,
offer, offer,
@ -186,10 +190,30 @@ export class SubscriptionService {
: SubscriptionType.Basic : SubscriptionType.Basic
}; };
} else { } else {
const offer = await this.getSubscriptionOffer({
key: 'default'
});
return { return {
offer: 'default', offer,
type: SubscriptionType.Basic type: SubscriptionType.Basic
}; };
} }
} }
public async getSubscriptionOffer({
key
}: {
key: SubscriptionOfferKey;
}): Promise<SubscriptionOffer> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
const offers =
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
{};
return offers[key];
}
} }

View File

@ -339,7 +339,7 @@ export class UserService {
} }
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription = this.subscriptionService.getSubscription({ user.subscription = await this.subscriptionService.getSubscription({
createdAt: user.createdAt, createdAt: user.createdAt,
subscriptions: Subscription subscriptions: Subscription
}); });
@ -392,6 +392,12 @@ export class UserService {
currentPermissions, currentPermissions,
permissions.deleteOwnUser permissions.deleteOwnUser
); );
// Reset offer
user.subscription.offer.coupon = undefined;
user.subscription.offer.couponId = undefined;
user.subscription.offer.durationExtension = undefined;
user.subscription.offer.label = undefined;
} }
} }

View File

@ -143,8 +143,8 @@ export class AppComponent implements OnDestroy, OnInit {
); );
this.hasPromotion = this.hasPromotion =
!!this.info?.subscriptionOffers?.default?.coupon || !!this.info?.subscriptionOffer?.coupon ||
!!this.info?.subscriptionOffers?.default?.durationExtension; !!this.info?.subscriptionOffer?.durationExtension;
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
@ -242,12 +242,8 @@ export class AppComponent implements OnDestroy, OnInit {
this.canCreateAccount || !!this.user?.systemMessage; this.canCreateAccount || !!this.user?.systemMessage;
this.hasPromotion = this.hasPromotion =
!!this.info?.subscriptionOffers?.[ !!this.user?.subscription?.offer?.coupon ||
this.user?.subscription?.offer ?? 'default' !!this.user?.subscription?.offer?.durationExtension;
]?.coupon ||
!!this.info?.subscriptionOffers?.[
this.user?.subscription?.offer ?? 'default'
]?.durationExtension;
this.initializeTheme(this.user?.settings.colorScheme); this.initializeTheme(this.user?.settings.colorScheme);

View File

@ -51,8 +51,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
private stripeService: StripeService, private stripeService: StripeService,
private userService: UserService private userService: UserService
) { ) {
const { baseCurrency, globalPermissions, subscriptionOffers } = const { baseCurrency, globalPermissions } = this.dataService.fetchInfo();
this.dataService.fetchInfo();
this.baseCurrency = baseCurrency; this.baseCurrency = baseCurrency;
@ -81,18 +80,12 @@ export class UserAccountMembershipComponent implements OnDestroy {
permissions.updateUserSettings permissions.updateUserSettings
); );
this.coupon = this.coupon = this.user?.subscription?.offer?.coupon;
subscriptionOffers?.[this.user.subscription.offer]?.coupon; this.couponId = this.user?.subscription?.offer?.couponId;
this.couponId =
subscriptionOffers?.[this.user.subscription.offer]?.couponId;
this.durationExtension = this.durationExtension =
subscriptionOffers?.[ this.user?.subscription?.offer?.durationExtension;
this.user.subscription.offer this.price = this.user?.subscription?.offer?.price;
]?.durationExtension; this.priceId = this.user?.subscription?.offer?.priceId;
this.price =
subscriptionOffers?.[this.user.subscription.offer]?.price;
this.priceId =
subscriptionOffers?.[this.user.subscription.offer]?.priceId;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }

View File

@ -55,13 +55,13 @@ export class PricingPageComponent implements OnDestroy, OnInit {
) {} ) {}
public ngOnInit() { public ngOnInit() {
const { baseCurrency, subscriptionOffers } = this.dataService.fetchInfo(); const { baseCurrency, subscriptionOffer } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency; this.baseCurrency = baseCurrency;
this.coupon = subscriptionOffers?.default?.coupon; this.coupon = subscriptionOffer?.coupon;
this.durationExtension = subscriptionOffers?.default?.durationExtension; this.durationExtension = subscriptionOffer?.durationExtension;
this.label = subscriptionOffers?.default?.label; this.label = subscriptionOffer?.label;
this.price = subscriptionOffers?.default?.price; this.price = subscriptionOffer?.price;
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -74,20 +74,13 @@ export class PricingPageComponent implements OnDestroy, OnInit {
permissions.updateUserSettings permissions.updateUserSettings
); );
this.coupon = this.coupon = this.user?.subscription?.offer?.coupon;
subscriptionOffers?.[this.user?.subscription?.offer]?.coupon; this.couponId = this.user?.subscription?.offer?.couponId;
this.couponId =
subscriptionOffers?.[this.user.subscription.offer]?.couponId;
this.durationExtension = this.durationExtension =
subscriptionOffers?.[ this.user?.subscription?.offer?.durationExtension;
this.user?.subscription?.offer this.label = this.user?.subscription?.offer?.label;
]?.durationExtension; this.price = this.user?.subscription?.offer?.price;
this.label = this.priceId = this.user?.subscription?.offer?.priceId;
subscriptionOffers?.[this.user?.subscription?.offer]?.label;
this.price =
subscriptionOffers?.[this.user?.subscription?.offer]?.price;
this.priceId =
subscriptionOffers?.[this.user.subscription.offer]?.priceId;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }

View File

@ -33,9 +33,9 @@ export class GfProductPageComponent implements OnInit {
) {} ) {}
public ngOnInit() { public ngOnInit() {
const { subscriptionOffers } = this.dataService.fetchInfo(); const { subscriptionOffer } = this.dataService.fetchInfo();
this.price = subscriptionOffers?.default?.price; this.price = subscriptionOffer?.price;
this.product1 = { this.product1 = {
founded: 2021, founded: 2021,

View File

@ -1,5 +1,3 @@
import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { Platform, SymbolProfile } from '@prisma/client'; import { Platform, SymbolProfile } from '@prisma/client';
import { Statistics } from './statistics.interface'; import { Statistics } from './statistics.interface';
@ -18,5 +16,5 @@ export interface InfoItem {
platforms: Platform[]; platforms: Platform[];
statistics: Statistics; statistics: Statistics;
stripePublicKey?: string; stripePublicKey?: string;
subscriptionOffers: { [offer in SubscriptionOfferKey]: SubscriptionOffer }; subscriptionOffer?: SubscriptionOffer;
} }

View File

@ -1,8 +1,8 @@
import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Access, Account, Tag } from '@prisma/client'; import { Access, Account, Tag } from '@prisma/client';
import { SubscriptionOffer } from './subscription-offer.interface';
import { SystemMessage } from './system-message.interface'; import { SystemMessage } from './system-message.interface';
import { UserSettings } from './user-settings.interface'; import { UserSettings } from './user-settings.interface';
@ -18,7 +18,7 @@ export interface User {
systemMessage?: SystemMessage; systemMessage?: SystemMessage;
subscription: { subscription: {
expiresAt?: Date; expiresAt?: Date;
offer: SubscriptionOfferKey; offer: SubscriptionOffer;
type: SubscriptionType; type: SubscriptionType;
}; };
tags: (Tag & { isUsed: boolean })[]; tags: (Tag & { isUsed: boolean })[];

View File

@ -17,6 +17,7 @@ import type { Market } from './market.type';
import type { OrderWithAccount } from './order-with-account.type'; import type { OrderWithAccount } from './order-with-account.type';
import type { RequestWithUser } from './request-with-user.type'; import type { RequestWithUser } from './request-with-user.type';
import type { SubscriptionOfferKey } from './subscription-offer-key.type'; import type { SubscriptionOfferKey } from './subscription-offer-key.type';
import type { SubscriptionType } from './subscription-type.type';
import type { UserWithSettings } from './user-with-settings.type'; import type { UserWithSettings } from './user-with-settings.type';
import type { ViewMode } from './view-mode.type'; import type { ViewMode } from './view-mode.type';
@ -40,6 +41,7 @@ export type {
OrderWithAccount, OrderWithAccount,
RequestWithUser, RequestWithUser,
SubscriptionOfferKey, SubscriptionOfferKey,
SubscriptionType,
UserWithSettings, UserWithSettings,
ViewMode ViewMode
}; };

View File

@ -1,6 +1,5 @@
import { UserSettings } from '@ghostfolio/common/interfaces'; import { SubscriptionOffer, UserSettings } from '@ghostfolio/common/interfaces';
import { SubscriptionOfferKey } from '@ghostfolio/common/types'; import { SubscriptionType } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Access, Account, Settings, User } from '@prisma/client'; import { Access, Account, Settings, User } from '@prisma/client';
@ -14,7 +13,7 @@ export type UserWithSettings = User & {
Settings: Settings & { settings: UserSettings }; Settings: Settings & { settings: UserSettings };
subscription?: { subscription?: {
expiresAt?: Date; expiresAt?: Date;
offer: SubscriptionOfferKey; offer: SubscriptionOffer;
type: SubscriptionType; type: SubscriptionType;
}; };
}; };