diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index bd291c511..f81ddd710 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -23,10 +23,10 @@ import { import { InfoItem, Statistics, - Subscription + SubscriptionOffer } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import { SubscriptionOffer } from '@ghostfolio/common/types'; +import { SubscriptionOfferKey } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; @@ -101,7 +101,7 @@ export class InfoService { isUserSignupEnabled, platforms, statistics, - subscriptions + subscriptionOffers ] = await Promise.all([ this.benchmarkService.getBenchmarkAssetProfiles(), this.getDemoAuthToken(), @@ -110,7 +110,7 @@ export class InfoService { orderBy: { name: 'asc' } }), this.getStatistics(), - this.getSubscriptions() + this.getSubscriptionOffers() ]); if (isUserSignupEnabled) { @@ -125,7 +125,7 @@ export class InfoService { isReadOnlyMode, platforms, statistics, - subscriptions, + subscriptionOffers, baseCurrency: DEFAULT_CURRENCY, currencies: this.exchangeRateDataService.getCurrencies() }; @@ -314,8 +314,8 @@ export class InfoService { return statistics; } - private async getSubscriptions(): Promise<{ - [offer in SubscriptionOffer]: Subscription; + private async getSubscriptionOffers(): Promise<{ + [offer in SubscriptionOfferKey]: SubscriptionOffer; }> { if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { return undefined; diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index 7c1df023c..47e6db00d 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -1,8 +1,16 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; -import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + DEFAULT_LANGUAGE_CODE, + PROPERTY_STRIPE_CONFIG +} from '@ghostfolio/common/config'; import { parseDate } from '@ghostfolio/common/helper'; -import { SubscriptionOffer, UserWithSettings } from '@ghostfolio/common/types'; +import { SubscriptionOffer } from '@ghostfolio/common/interfaces'; +import { + SubscriptionOfferKey, + UserWithSettings +} from '@ghostfolio/common/types'; import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type'; import { Injectable, Logger } from '@nestjs/common'; @@ -17,7 +25,8 @@ export class SubscriptionService { public constructor( private readonly configurationService: ConfigurationService, - private readonly prismaService: PrismaService + private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService ) { this.stripe = new Stripe( this.configurationService.get('STRIPE_SECRET_KEY'), @@ -36,6 +45,18 @@ export class SubscriptionService { priceId: string; user: UserWithSettings; }) { + const subscriptionOffers: { + [offer in SubscriptionOfferKey]: SubscriptionOffer; + } = + ((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ?? + {}; + + const subscriptionOffer = Object.values(subscriptionOffers).find( + (subscriptionOffer) => { + return subscriptionOffer.priceId === priceId; + } + ); + const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = { cancel_url: `${this.configurationService.get('ROOT_URL')}/${ user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE @@ -47,6 +68,13 @@ export class SubscriptionService { quantity: 1 } ], + locale: + (user.Settings?.settings + ?.language as Stripe.Checkout.SessionCreateParams.Locale) ?? + DEFAULT_LANGUAGE_CODE, + metadata: subscriptionOffer + ? { subscriptionOffer: JSON.stringify(subscriptionOffer) } + : {}, mode: 'payment', payment_method_types: ['card'], success_url: `${this.configurationService.get( @@ -73,17 +101,25 @@ export class SubscriptionService { public async createSubscription({ duration = '1 year', + durationExtension, price, userId }: { duration?: StringValue; + durationExtension?: StringValue; price: number; userId: string; }) { + let expiresAt = addMilliseconds(new Date(), ms(duration)); + + if (durationExtension) { + expiresAt = addMilliseconds(expiresAt, ms(durationExtension)); + } + await this.prismaService.subscription.create({ data: { + expiresAt, price, - expiresAt: addMilliseconds(new Date(), ms(duration)), User: { connect: { id: userId @@ -95,10 +131,21 @@ export class SubscriptionService { public async createSubscriptionViaStripe(aCheckoutSessionId: string) { try { + let durationExtension: StringValue; + const session = await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId); + const subscriptionOffer: SubscriptionOffer = JSON.parse( + session.metadata.subscriptionOffer ?? '{}' + ); + + if (subscriptionOffer) { + durationExtension = subscriptionOffer.durationExtension; + } + await this.createSubscription({ + durationExtension, price: session.amount_total / 100, userId: session.client_reference_id }); @@ -121,7 +168,7 @@ export class SubscriptionService { return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b; }); - let offer: SubscriptionOffer = price ? 'renewal' : 'default'; + let offer: SubscriptionOfferKey = price ? 'renewal' : 'default'; if (isBefore(createdAt, parseDate('2023-01-01'))) { offer = 'renewal-early-bird-2023'; diff --git a/apps/client/src/app/app.component.html b/apps/client/src/app/app.component.html index b12855488..7560e15e5 100644 --- a/apps/client/src/app/app.component.html +++ b/apps/client/src/app/app.component.html @@ -33,6 +33,7 @@ [deviceType]="deviceType" [hasPermissionToChangeDateRange]="hasPermissionToChangeDateRange" [hasPermissionToChangeFilters]="hasPermissionToChangeFilters" + [hasPromotion]="hasPromotion" [hasTabs]="hasTabs" [info]="info" [pageTitle]="pageTitle" diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index 75841686c..86d4282a2 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -57,6 +57,7 @@ export class AppComponent implements OnDestroy, OnInit { public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToChangeDateRange: boolean; public hasPermissionToChangeFilters: boolean; + public hasPromotion = false; public hasTabs = false; public info: InfoItem; public pageTitle: string; @@ -136,6 +137,10 @@ export class AppComponent implements OnDestroy, OnInit { permissions.enableFearAndGreedIndex ); + this.hasPromotion = + !!this.info?.subscriptionOffers?.default?.coupon || + !!this.info?.subscriptionOffers?.default?.durationExtension; + this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntil(this.unsubscribeSubject)) @@ -231,6 +236,14 @@ export class AppComponent implements OnDestroy, OnInit { this.hasInfoMessage = this.canCreateAccount || !!this.user?.systemMessage; + this.hasPromotion = + !!this.info?.subscriptionOffers?.[ + this.user?.subscription?.offer ?? 'default' + ]?.coupon || + !!this.info?.subscriptionOffers?.[ + this.user?.subscription?.offer ?? 'default' + ]?.durationExtension; + this.initializeTheme(this.user?.settings.colorScheme); this.changeDetectorRef.markForCheck(); diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index ff36c2ebe..8a611d935 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -88,15 +88,20 @@
  • Pricing + + Pricing + @if (currentRoute !== routePricing && hasPromotion) { + % + } + +
  • }
  • @@ -290,12 +295,17 @@ ) { Pricing + + Pricing + @if (currentRoute !== routePricing && hasPromotion) { + % + } + + } Pricing + + Pricing + @if (currentRoute !== routePricing && hasPromotion) { + % + } + +
  • } @if (hasPermissionToAccessFearAndGreedIndex) { diff --git a/apps/client/src/app/components/header/header.component.ts b/apps/client/src/app/components/header/header.component.ts index 33069aa23..1739d113f 100644 --- a/apps/client/src/app/components/header/header.component.ts +++ b/apps/client/src/app/components/header/header.component.ts @@ -58,6 +58,7 @@ export class HeaderComponent implements OnChanges { @Input() deviceType: string; @Input() hasPermissionToChangeDateRange: boolean; @Input() hasPermissionToChangeFilters: boolean; + @Input() hasPromotion: boolean; @Input() hasTabs: boolean; @Input() info: InfoItem; @Input() pageTitle: string; diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts index 93bbe641c..bde555d8e 100644 --- a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts @@ -16,6 +16,7 @@ import { MatSnackBarRef, TextOnlySnackBar } from '@angular/material/snack-bar'; +import { StringValue } from 'ms'; import { StripeService } from 'ngx-stripe'; import { EMPTY, Subject } from 'rxjs'; import { catchError, switchMap, takeUntil } from 'rxjs/operators'; @@ -31,6 +32,7 @@ export class UserAccountMembershipComponent implements OnDestroy { public coupon: number; public couponId: string; public defaultDateFormat: string; + public durationExtension: StringValue; public hasPermissionForSubscription: boolean; public hasPermissionToUpdateUserSettings: boolean; public price: number; @@ -51,7 +53,7 @@ export class UserAccountMembershipComponent implements OnDestroy { private stripeService: StripeService, private userService: UserService ) { - const { baseCurrency, globalPermissions, subscriptions } = + const { baseCurrency, globalPermissions, subscriptionOffers } = this.dataService.fetchInfo(); this.baseCurrency = baseCurrency; @@ -76,11 +78,18 @@ export class UserAccountMembershipComponent implements OnDestroy { permissions.updateUserSettings ); - this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon; + this.coupon = + subscriptionOffers?.[this.user.subscription.offer]?.coupon; this.couponId = - subscriptions?.[this.user.subscription.offer]?.couponId; - this.price = subscriptions?.[this.user.subscription.offer]?.price; - this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId; + subscriptionOffers?.[this.user.subscription.offer]?.couponId; + this.durationExtension = + subscriptionOffers?.[ + this.user.subscription.offer + ]?.durationExtension; + this.price = + subscriptionOffers?.[this.user.subscription.offer]?.price; + this.priceId = + subscriptionOffers?.[this.user.subscription.offer]?.priceId; this.changeDetectorRef.markForCheck(); } diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.html b/apps/client/src/app/components/user-account-membership/user-account-membership.html index d30ce7bdd..82b329a64 100644 --- a/apps/client/src/app/components/user-account-membership/user-account-membership.html +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.html @@ -34,6 +34,16 @@  per year } + @if (durationExtension) { +
    +
    + Limited Offer! Get + {{ durationExtension }} extra +
    +
    + } }
    @if (!user?.subscription?.expiresAt) { diff --git a/apps/client/src/app/pages/pricing/pricing-page.component.ts b/apps/client/src/app/pages/pricing/pricing-page.component.ts index 8bd0f1bd5..f86a75904 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.component.ts +++ b/apps/client/src/app/pages/pricing/pricing-page.component.ts @@ -6,6 +6,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { translate } from '@ghostfolio/ui/i18n'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { StringValue } from 'ms'; import { StripeService } from 'ngx-stripe'; import { Subject } from 'rxjs'; import { catchError, switchMap, takeUntil } from 'rxjs/operators'; @@ -20,6 +21,7 @@ export class PricingPageComponent implements OnDestroy, OnInit { public baseCurrency: string; public coupon: number; public couponId: string; + public durationExtension: StringValue; public hasPermissionToUpdateUserSettings: boolean; public importAndExportTooltipBasic = translate( 'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC' @@ -51,11 +53,12 @@ export class PricingPageComponent implements OnDestroy, OnInit { ) {} public ngOnInit() { - const { baseCurrency, subscriptions } = this.dataService.fetchInfo(); + const { baseCurrency, subscriptionOffers } = this.dataService.fetchInfo(); this.baseCurrency = baseCurrency; - this.coupon = subscriptions?.default?.coupon; - this.price = subscriptions?.default?.price; + this.coupon = subscriptionOffers?.default?.coupon; + this.durationExtension = subscriptionOffers?.default?.durationExtension; + this.price = subscriptionOffers?.default?.price; this.userService.stateChanged .pipe(takeUntil(this.unsubscribeSubject)) @@ -68,11 +71,18 @@ export class PricingPageComponent implements OnDestroy, OnInit { permissions.updateUserSettings ); - this.coupon = subscriptions?.[this.user?.subscription?.offer]?.coupon; + this.coupon = + subscriptionOffers?.[this.user?.subscription?.offer]?.coupon; this.couponId = - subscriptions?.[this.user.subscription.offer]?.couponId; - this.price = subscriptions?.[this.user?.subscription?.offer]?.price; - this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId; + subscriptionOffers?.[this.user.subscription.offer]?.couponId; + this.durationExtension = + subscriptionOffers?.[ + this.user?.subscription?.offer + ]?.durationExtension; + this.price = + subscriptionOffers?.[this.user?.subscription?.offer]?.price; + this.priceId = + subscriptionOffers?.[this.user.subscription.offer]?.priceId; this.changeDetectorRef.markForCheck(); } diff --git a/apps/client/src/app/pages/pricing/pricing-page.html b/apps/client/src/app/pages/pricing/pricing-page.html index fe805ef62..605ad5d2e 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.html +++ b/apps/client/src/app/pages/pricing/pricing-page.html @@ -101,6 +101,11 @@

    } + @if (durationExtension) { + + } @@ -159,6 +164,11 @@

    } + @if (durationExtension) { + + } @@ -289,6 +299,14 @@

    } + @if (durationExtension) { +
    +
    + Limited Offer! Get + {{ durationExtension }} extra +
    +
    + } diff --git a/apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts b/apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts index ea14bbc6b..39dbc4813 100644 --- a/apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts +++ b/apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts @@ -35,9 +35,9 @@ export class GfProductPageComponent implements OnInit { ) {} public ngOnInit() { - const { subscriptions } = this.dataService.fetchInfo(); + const { subscriptionOffers } = this.dataService.fetchInfo(); - this.price = subscriptions?.default?.price; + this.price = subscriptionOffers?.default?.price; this.product1 = { founded: 2021, diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 0ec04594c..becc872dd 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -46,7 +46,7 @@ import type { PortfolioPerformanceResponse } from './responses/portfolio-perform import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface'; import type { ScraperConfiguration } from './scraper-configuration.interface'; import type { Statistics } from './statistics.interface'; -import type { Subscription } from './subscription.interface'; +import type { SubscriptionOffer } from './subscription-offer.interface'; import type { SymbolMetrics } from './symbol-metrics.interface'; import type { SystemMessage } from './system-message.interface'; import type { TabConfiguration } from './tab-configuration.interface'; @@ -102,8 +102,8 @@ export { ResponseError, ScraperConfiguration, Statistics, + SubscriptionOffer, SystemMessage, - Subscription, SymbolMetrics, TabConfiguration, ToggleOption, diff --git a/libs/common/src/lib/interfaces/info-item.interface.ts b/libs/common/src/lib/interfaces/info-item.interface.ts index 1b3926331..bd3eb1f94 100644 --- a/libs/common/src/lib/interfaces/info-item.interface.ts +++ b/libs/common/src/lib/interfaces/info-item.interface.ts @@ -1,9 +1,9 @@ -import { SubscriptionOffer } from '@ghostfolio/common/types'; +import { SubscriptionOfferKey } from '@ghostfolio/common/types'; import { Platform, SymbolProfile } from '@prisma/client'; import { Statistics } from './statistics.interface'; -import { Subscription } from './subscription.interface'; +import { SubscriptionOffer } from './subscription-offer.interface'; export interface InfoItem { baseCurrency: string; @@ -18,5 +18,5 @@ export interface InfoItem { platforms: Platform[]; statistics: Statistics; stripePublicKey?: string; - subscriptions: { [offer in SubscriptionOffer]: Subscription }; + subscriptionOffers: { [offer in SubscriptionOfferKey]: SubscriptionOffer }; } diff --git a/libs/common/src/lib/interfaces/subscription-offer.interface.ts b/libs/common/src/lib/interfaces/subscription-offer.interface.ts new file mode 100644 index 000000000..8db91da6e --- /dev/null +++ b/libs/common/src/lib/interfaces/subscription-offer.interface.ts @@ -0,0 +1,9 @@ +import { StringValue } from 'ms'; + +export interface SubscriptionOffer { + coupon?: number; + couponId?: string; + durationExtension?: StringValue; + price: number; + priceId: string; +} diff --git a/libs/common/src/lib/interfaces/subscription.interface.ts b/libs/common/src/lib/interfaces/subscription.interface.ts deleted file mode 100644 index 29f5d3aba..000000000 --- a/libs/common/src/lib/interfaces/subscription.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface Subscription { - coupon?: number; - couponId?: string; - price: number; - priceId: string; -} diff --git a/libs/common/src/lib/interfaces/user.interface.ts b/libs/common/src/lib/interfaces/user.interface.ts index 27cd1a610..647822d34 100644 --- a/libs/common/src/lib/interfaces/user.interface.ts +++ b/libs/common/src/lib/interfaces/user.interface.ts @@ -1,4 +1,4 @@ -import { SubscriptionOffer } from '@ghostfolio/common/types'; +import { SubscriptionOfferKey } from '@ghostfolio/common/types'; import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type'; import { Account, Tag } from '@prisma/client'; @@ -20,7 +20,7 @@ export interface User { systemMessage?: SystemMessage; subscription: { expiresAt?: Date; - offer: SubscriptionOffer; + offer: SubscriptionOfferKey; type: SubscriptionType; }; tags: (Tag & { isUsed: boolean })[]; diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts index a66755ab1..9e8178d3c 100644 --- a/libs/common/src/lib/types/index.ts +++ b/libs/common/src/lib/types/index.ts @@ -15,7 +15,7 @@ import type { MarketState } from './market-state.type'; import type { Market } from './market.type'; import type { OrderWithAccount } from './order-with-account.type'; import type { RequestWithUser } from './request-with-user.type'; -import type { SubscriptionOffer } from './subscription-offer.type'; +import type { SubscriptionOfferKey } from './subscription-offer-key.type'; import type { UserWithSettings } from './user-with-settings.type'; import type { ViewMode } from './view-mode.type'; @@ -37,7 +37,7 @@ export type { MarketState, OrderWithAccount, RequestWithUser, - SubscriptionOffer, + SubscriptionOfferKey, UserWithSettings, ViewMode }; diff --git a/libs/common/src/lib/types/subscription-offer.type.ts b/libs/common/src/lib/types/subscription-offer-key.type.ts similarity index 71% rename from libs/common/src/lib/types/subscription-offer.type.ts rename to libs/common/src/lib/types/subscription-offer-key.type.ts index 98977da45..f6d898a01 100644 --- a/libs/common/src/lib/types/subscription-offer.type.ts +++ b/libs/common/src/lib/types/subscription-offer-key.type.ts @@ -1,4 +1,4 @@ -export type SubscriptionOffer = +export type SubscriptionOfferKey = | 'default' | 'renewal' | 'renewal-early-bird-2023' 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 59e9f142d..2a669d26f 100644 --- a/libs/common/src/lib/types/user-with-settings.type.ts +++ b/libs/common/src/lib/types/user-with-settings.type.ts @@ -1,5 +1,5 @@ import { UserSettings } from '@ghostfolio/common/interfaces'; -import { SubscriptionOffer } from '@ghostfolio/common/types'; +import { SubscriptionOfferKey } from '@ghostfolio/common/types'; import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type'; import { Access, Account, Settings, User } from '@prisma/client'; @@ -13,7 +13,7 @@ export type UserWithSettings = User & { Settings: Settings & { settings: UserSettings }; subscription?: { expiresAt?: Date; - offer: SubscriptionOffer; + offer: SubscriptionOfferKey; type: SubscriptionType; }; };