From ad00cd9d8155b2acdf626466f5019885579e96f4 Mon Sep 17 00:00:00 2001 From: Thomas <4159106+dtslvr@users.noreply.github.com> Date: Mon, 21 Jun 2021 20:03:36 +0200 Subject: [PATCH] Feature/setup subscription with stripe (#178) * Set up stripe for subscriptions * Update permissions and add discount * Update changelog --- CHANGELOG.md | 6 ++ apps/api/src/app/app.module.ts | 2 + apps/api/src/app/info/info.service.ts | 80 ++++++++++------- .../subscription/subscription.controller.ts | 57 ++++++++++++ .../app/subscription/subscription.module.ts | 13 +++ .../app/subscription/subscription.service.ts | 88 +++++++++++++++++++ .../user-settings-params.interface.ts | 7 ++ apps/api/src/app/user/user.controller.ts | 19 +++- apps/api/src/app/user/user.service.ts | 36 ++++---- .../api/src/services/configuration.service.ts | 1 + .../interfaces/environment.interface.ts | 1 + apps/client/src/app/app.module.ts | 5 +- .../pages/account/account-page.component.ts | 39 +++++++- .../src/app/pages/account/account-page.html | 23 +++-- .../pages/accounts/accounts-page.module.ts | 4 +- .../create-or-update-account-dialog.html | 4 +- .../create-or-update-account-dialog.module.ts | 2 +- .../pages/pricing/pricing-page.component.ts | 16 +++- .../src/app/pages/pricing/pricing-page.html | 19 +++- apps/client/src/app/services/data.service.ts | 13 +++ .../src/environments/environment.prod.ts | 1 + apps/client/src/environments/environment.ts | 1 + .../src/lib/interfaces/info-item.interface.ts | 2 + .../lib/interfaces/subscription.interface.ts | 6 ++ .../lib/interfaces/user-settings.interface.ts | 4 +- .../src/lib/interfaces/user-with-settings.ts | 1 + libs/common/src/lib/permissions.ts | 9 +- package.json | 6 +- replace.build.js | 28 ++++-- yarn.lock | 41 +++++++++ 30 files changed, 453 insertions(+), 81 deletions(-) create mode 100644 apps/api/src/app/subscription/subscription.controller.ts create mode 100644 apps/api/src/app/subscription/subscription.module.ts create mode 100644 apps/api/src/app/subscription/subscription.service.ts create mode 100644 apps/api/src/app/user/interfaces/user-settings-params.interface.ts create mode 100644 libs/common/src/lib/interfaces/subscription.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ae4b775..c5568b82a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Set up _Stripe_ for subscriptions + ## 1.19.0 - 17.06.2021 ### Added diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index f9644b2a0..c0f4ff88a 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -27,6 +27,7 @@ import { InfoModule } from './info/info.module'; import { OrderModule } from './order/order.module'; import { PortfolioModule } from './portfolio/portfolio.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module'; +import { SubscriptionModule } from './subscription/subscription.module'; import { SymbolModule } from './symbol/symbol.module'; import { UserModule } from './user/user.module'; @@ -59,6 +60,7 @@ import { UserModule } from './user/user.module'; rootPath: join(__dirname, '..', 'client'), exclude: ['/api*'] }), + SubscriptionModule, SymbolModule, UserModule ], diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 580d39f35..a293ef4e6 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -1,6 +1,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { InfoItem } from '@ghostfolio/common/interfaces'; +import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface'; import { permissions } from '@ghostfolio/common/permissions'; import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; @@ -44,37 +45,8 @@ export class InfoService { currencies: Object.values(Currency), demoAuthToken: this.getDemoAuthToken(), lastDataGathering: await this.getLastDataGathering(), - statistics: await this.getStatistics() - }; - } - - private getDemoAuthToken() { - return this.jwtService.sign({ - id: InfoService.DEMO_USER_ID - }); - } - - private async getLastDataGathering() { - const lastDataGathering = await this.prisma.property.findUnique({ - where: { key: 'LAST_DATA_GATHERING' } - }); - - return lastDataGathering?.value ? new Date(lastDataGathering.value) : null; - } - - private async getStatistics() { - if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) { - return undefined; - } - - const activeUsers1d = await this.countActiveUsers(1); - const activeUsers30d = await this.countActiveUsers(30); - const gitHubStargazers = await this.countGitHubStargazers(); - - return { - activeUsers1d, - activeUsers30d, - gitHubStargazers + statistics: await this.getStatistics(), + subscriptions: await this.getSubscriptions() }; } @@ -124,4 +96,50 @@ export class InfoService { return undefined; } } + + private getDemoAuthToken() { + return this.jwtService.sign({ + id: InfoService.DEMO_USER_ID + }); + } + + private async getLastDataGathering() { + const lastDataGathering = await this.prisma.property.findUnique({ + where: { key: 'LAST_DATA_GATHERING' } + }); + + return lastDataGathering?.value ? new Date(lastDataGathering.value) : null; + } + + private async getStatistics() { + if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) { + return undefined; + } + + const activeUsers1d = await this.countActiveUsers(1); + const activeUsers30d = await this.countActiveUsers(30); + const gitHubStargazers = await this.countGitHubStargazers(); + + return { + activeUsers1d, + activeUsers30d, + gitHubStargazers + }; + } + + private async getSubscriptions(): Promise { + if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + return undefined; + } + + const stripeConfig = await this.prisma.property.findUnique({ + where: { key: 'STRIPE_CONFIG' } + }); + + if (stripeConfig) { + return [JSON.parse(stripeConfig.value)]; + } + + return []; + } } diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts new file mode 100644 index 000000000..ecd326ead --- /dev/null +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -0,0 +1,57 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { RequestWithUser } from '@ghostfolio/common/types'; +import { + Body, + Controller, + Get, + HttpException, + Inject, + Post, + Req, + Res, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { SubscriptionService } from './subscription.service'; + +@Controller('subscription') +export class SubscriptionController { + public constructor( + private readonly configurationService: ConfigurationService, + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly subscriptionService: SubscriptionService + ) {} + + @Get('stripe/callback') + public async stripeCallback(@Req() req, @Res() res) { + await this.subscriptionService.createSubscription( + req.query.checkoutSessionId + ); + + res.redirect(`${this.configurationService.get('ROOT_URL')}/account`); + } + + @Post('stripe/checkout-session') + @UseGuards(AuthGuard('jwt')) + public async createCheckoutSession( + @Body() { couponId, priceId }: { couponId: string; priceId: string } + ) { + try { + return await this.subscriptionService.createCheckoutSession({ + couponId, + priceId, + userId: this.request.user.id + }); + } catch (error) { + console.error(error); + + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + } +} diff --git a/apps/api/src/app/subscription/subscription.module.ts b/apps/api/src/app/subscription/subscription.module.ts new file mode 100644 index 000000000..34591d55a --- /dev/null +++ b/apps/api/src/app/subscription/subscription.module.ts @@ -0,0 +1,13 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { Module } from '@nestjs/common'; + +import { SubscriptionController } from './subscription.controller'; +import { SubscriptionService } from './subscription.service'; + +@Module({ + imports: [], + controllers: [SubscriptionController], + providers: [ConfigurationService, PrismaService, SubscriptionService] +}) +export class SubscriptionModule {} diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts new file mode 100644 index 000000000..fa77a55fc --- /dev/null +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -0,0 +1,88 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { addDays } from 'date-fns'; +import Stripe from 'stripe'; + +@Injectable() +export class SubscriptionService { + private stripe: Stripe; + + public constructor( + private readonly configurationService: ConfigurationService, + private prisma: PrismaService + ) { + this.stripe = new Stripe( + this.configurationService.get('STRIPE_SECRET_KEY'), + { + apiVersion: '2020-08-27' + } + ); + } + + public async createCheckoutSession({ + couponId, + priceId, + userId + }: { + couponId?: string; + priceId: string; + userId: string; + }) { + const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = { + cancel_url: `${this.configurationService.get('ROOT_URL')}/account`, + client_reference_id: userId, + line_items: [ + { + price: priceId, + quantity: 1 + } + ], + metadata: { + user_id: userId + }, + mode: 'subscription', + payment_method_types: ['card'], + success_url: `${this.configurationService.get( + 'ROOT_URL' + )}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}` + }; + + if (couponId) { + checkoutSessionCreateParams.discounts = [ + { + coupon: couponId + } + ]; + } + + const session = await this.stripe.checkout.sessions.create( + checkoutSessionCreateParams + ); + + return { + sessionId: session.id + }; + } + + public async createSubscription(aCheckoutSessionId: string) { + try { + const session = await this.stripe.checkout.sessions.retrieve( + aCheckoutSessionId + ); + + await this.prisma.subscription.create({ + data: { + expiresAt: addDays(new Date(), 365), + User: { + connect: { + id: session.client_reference_id + } + } + } + }); + } catch (error) { + console.error(error); + } + } +} diff --git a/apps/api/src/app/user/interfaces/user-settings-params.interface.ts b/apps/api/src/app/user/interfaces/user-settings-params.interface.ts new file mode 100644 index 000000000..e1e369e11 --- /dev/null +++ b/apps/api/src/app/user/interfaces/user-settings-params.interface.ts @@ -0,0 +1,7 @@ +import { Currency, ViewMode } from '@prisma/client'; + +export interface UserSettingsParams { + currency?: Currency; + userId: string; + viewMode?: ViewMode; +} diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index f7e5d275b..fd07beaec 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -25,6 +25,7 @@ import { User as UserModel } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { UserItem } from './interfaces/user-item.interface'; +import { UserSettingsParams } from './interfaces/user-settings-params.interface'; import { UpdateUserSettingsDto } from './update-user-settings.dto'; import { UserService } from './user.service'; @@ -92,10 +93,20 @@ export class UserController { ); } - return await this.userService.updateUserSettings({ + const userSettings: UserSettingsParams = { currency: data.baseCurrency, - userId: this.request.user.id, - viewMode: data.viewMode - }); + userId: this.request.user.id + }; + + if ( + hasPermission( + getPermissions(this.request.user.role), + permissions.updateViewMode + ) + ) { + userSettings.viewMode = data.viewMode; + } + + return await this.userService.updateUserSettings(userSettings); } } diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index ff018ed69..fe3441c36 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -1,13 +1,14 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { locale } from '@ghostfolio/common/config'; -import { resetHours } from '@ghostfolio/common/helper'; import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; import { getPermissions, permissions } from '@ghostfolio/common/permissions'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { Injectable } from '@nestjs/common'; import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client'; -import { add, isBefore } from 'date-fns'; +import { isBefore } from 'date-fns'; + +import { UserSettingsParams } from './interfaces/user-settings-params.interface'; const crypto = require('crypto'); @@ -24,7 +25,7 @@ export class UserService { Account, alias, id, - role, + permissions, Settings, subscription }: UserWithSettings): Promise { @@ -36,15 +37,10 @@ export class UserService { where: { GranteeUser: { id } } }); - const currentPermissions = getPermissions(role); - - if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { - currentPermissions.push(permissions.accessFearAndGreedIndex); - } - return { alias, id, + permissions, subscription, access: access.map((accessItem) => { return { @@ -53,7 +49,6 @@ export class UserService { }; }), accounts: Account, - permissions: currentPermissions, settings: { locale, baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY, @@ -72,6 +67,14 @@ export class UserService { const user: UserWithSettings = userFromDatabase; + const currentPermissions = getPermissions(userFromDatabase.role); + + if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { + currentPermissions.push(permissions.accessFearAndGreedIndex); + } + + user.permissions = currentPermissions; + if (userFromDatabase?.Settings) { if (!userFromDatabase.Settings.currency) { // Set default currency if needed @@ -106,6 +109,13 @@ export class UserService { type: SubscriptionType.Basic }; } + + if (user.subscription.type === SubscriptionType.Basic) { + user.permissions = user.permissions.filter((permission) => { + return permission !== permissions.updateViewMode; + }); + user.Settings.viewMode = ViewMode.ZEN; + } } return user; @@ -213,11 +223,7 @@ export class UserService { currency, userId, viewMode - }: { - currency?: Currency; - userId: string; - viewMode?: ViewMode; - }) { + }: UserSettingsParams) { await this.prisma.settings.upsert({ create: { currency, diff --git a/apps/api/src/services/configuration.service.ts b/apps/api/src/services/configuration.service.ts index af17e9989..f605032e5 100644 --- a/apps/api/src/services/configuration.service.ts +++ b/apps/api/src/services/configuration.service.ts @@ -28,6 +28,7 @@ export class ConfigurationService { REDIS_HOST: str({ default: 'localhost' }), REDIS_PORT: port({ default: 6379 }), ROOT_URL: str({ default: 'http://localhost:4200' }), + STRIPE_SECRET_KEY: str({ default: '' }), WEB_AUTH_RP_ID: host({ default: 'localhost' }) }); } diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 8d344c60f..520682247 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -19,5 +19,6 @@ export interface Environment extends CleanedEnvAccessors { REDIS_HOST: string; REDIS_PORT: number; ROOT_URL: string; + STRIPE_SECRET_KEY: string; WEB_AUTH_RP_ID: string; } diff --git a/apps/client/src/app/app.module.ts b/apps/client/src/app/app.module.ts index 552013e12..6ccb38c04 100644 --- a/apps/client/src/app/app.module.ts +++ b/apps/client/src/app/app.module.ts @@ -15,7 +15,9 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { MaterialCssVarsModule } from 'angular-material-css-vars'; import { MarkdownModule } from 'ngx-markdown'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { NgxStripeModule } from 'ngx-stripe'; +import { environment } from '../environments/environment'; import { CustomDateAdapter } from './adapter/custom-date-adapter'; import { DateFormats } from './adapter/date-formats'; import { AppRoutingModule } from './app-routing.module'; @@ -43,7 +45,8 @@ import { LanguageService } from './core/language.service'; }), MatNativeDateModule, MatSnackBarModule, - NgxSkeletonLoaderModule + NgxSkeletonLoaderModule, + NgxStripeModule.forRoot(environment.stripePublicKey) ], providers: [ authInterceptorProviders, diff --git a/apps/client/src/app/pages/account/account-page.component.ts b/apps/client/src/app/pages/account/account-page.component.ts index 85ca88611..3cca89d56 100644 --- a/apps/client/src/app/pages/account/account-page.component.ts +++ b/apps/client/src/app/pages/account/account-page.component.ts @@ -16,8 +16,9 @@ import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { Access, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { Currency } from '@prisma/client'; +import { StripeService } from 'ngx-stripe'; import { EMPTY, Subject } from 'rxjs'; -import { catchError, takeUntil } from 'rxjs/operators'; +import { catchError, switchMap, takeUntil } from 'rxjs/operators'; @Component({ selector: 'gf-account-page', @@ -30,9 +31,14 @@ export class AccountPageComponent implements OnDestroy, OnInit { public accesses: Access[]; public baseCurrency: Currency; + public coupon: number; + public couponId: string; public currencies: Currency[] = []; public defaultDateFormat = DEFAULT_DATE_FORMAT; + public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateUserSettings: boolean; + public price: number; + public priceId: string; public user: User; private unsubscribeSubject = new Subject(); @@ -43,14 +49,21 @@ export class AccountPageComponent implements OnDestroy, OnInit { public constructor( private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, + private stripeService: StripeService, private userService: UserService, public webAuthnService: WebAuthnService ) { this.dataService .fetchInfo() .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ currencies }) => { + .subscribe(({ currencies, subscriptions }) => { + this.coupon = subscriptions?.[0]?.coupon; + this.couponId = subscriptions?.[0]?.couponId; this.currencies = currencies; + this.price = subscriptions?.[0]?.price; + this.priceId = subscriptions?.[0]?.priceId; + + this.changeDetectorRef.markForCheck(); }); this.userService.stateChanged @@ -64,6 +77,11 @@ export class AccountPageComponent implements OnDestroy, OnInit { permissions.updateUserSettings ); + this.hasPermissionToUpdateViewMode = hasPermission( + this.user.permissions, + permissions.updateViewMode + ); + this.changeDetectorRef.markForCheck(); } }); @@ -99,6 +117,23 @@ export class AccountPageComponent implements OnDestroy, OnInit { }); } + public onCheckout() { + this.dataService + .createCheckoutSession({ couponId: this.couponId, priceId: this.priceId }) + .pipe( + switchMap(({ sessionId }: { sessionId: string }) => { + return this.stripeService.redirectToCheckout({ + sessionId + }); + }) + ) + .subscribe((result) => { + if (result.error) { + alert(result.error.message); + } + }); + } + public onSignInWithFingerprintChange(aEvent: MatSlideToggleChange) { if (aEvent.checked) { this.registerDevice(); diff --git a/apps/client/src/app/pages/account/account-page.html b/apps/client/src/app/pages/account/account-page.html index 82d077e46..0662d78a9 100644 --- a/apps/client/src/app/pages/account/account-page.html +++ b/apps/client/src/app/pages/account/account-page.html @@ -1,10 +1,7 @@
-

- {{ user.alias }} - Account -

+

Account

@@ -26,9 +23,23 @@ defaultDateFormat }}
- +
+ {{ user.settings.baseCurrency }} + {{ price - coupon }} + {{ user.settings.baseCurrency }} {{ price }} + + {{ price }} + per year +
@@ -55,7 +66,7 @@ View Mode diff --git a/apps/client/src/app/pages/accounts/accounts-page.module.ts b/apps/client/src/app/pages/accounts/accounts-page.module.ts index ec0212ed5..b9de21ff8 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.module.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.module.ts @@ -6,7 +6,7 @@ import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-ta import { AccountsPageRoutingModule } from './accounts-page-routing.module'; import { AccountsPageComponent } from './accounts-page.component'; -import { CreateOrUpdateAccountDialogModule } from './create-or-update-account-dialog/create-or-update-account-dialog.module'; +import { GfCreateOrUpdateAccountDialogModule } from './create-or-update-account-dialog/create-or-update-account-dialog.module'; @NgModule({ declarations: [AccountsPageComponent], @@ -14,8 +14,8 @@ import { CreateOrUpdateAccountDialogModule } from './create-or-update-account-di imports: [ AccountsPageRoutingModule, CommonModule, - CreateOrUpdateAccountDialogModule, GfAccountsTableModule, + GfCreateOrUpdateAccountDialogModule, MatButtonModule, RouterModule ], diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html index 5c0eef057..25c3fc430 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -1,6 +1,6 @@
-

Update account

-

Add account

+

Update account

+

Add account

diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts index 4d7b0da77..ce5fed226 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts @@ -24,4 +24,4 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c ], providers: [] }) -export class CreateOrUpdateAccountDialogModule {} +export class GfCreateOrUpdateAccountDialogModule {} 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 fe4a4df8b..5f17ce229 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.component.ts +++ b/apps/client/src/app/pages/pricing/pricing-page.component.ts @@ -1,4 +1,5 @@ import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { baseCurrency } from '@ghostfolio/common/config'; import { User } from '@ghostfolio/common/interfaces'; @@ -12,7 +13,9 @@ import { takeUntil } from 'rxjs/operators'; }) export class PricingPageComponent implements OnInit { public baseCurrency = baseCurrency; + public coupon: number; public isLoggedIn: boolean; + public price: number; public user: User; private unsubscribeSubject = new Subject(); @@ -22,8 +25,19 @@ export class PricingPageComponent implements OnInit { */ public constructor( private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, private userService: UserService - ) {} + ) { + this.dataService + .fetchInfo() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ subscriptions }) => { + this.coupon = this.price = subscriptions?.[0]?.coupon; + this.price = subscriptions?.[0]?.price; + + this.changeDetectorRef.markForCheck(); + }); + } /** * Initializes the controller diff --git a/apps/client/src/app/pages/pricing/pricing-page.html b/apps/client/src/app/pages/pricing/pricing-page.html index b912e6d1a..ffd86fbb2 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.html +++ b/apps/client/src/app/pages/pricing/pricing-page.html @@ -176,11 +176,17 @@

Fully managed Ghostfolio cloud offering.

-

+

{{ user?.settings.baseCurrency || baseCurrency }} - 0.00 - 3.99 / Month{{ price - coupon }} + {{ user.settings.baseCurrency }} {{ price }} + + {{ price }} + per year

@@ -188,6 +194,13 @@
+
diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index bf41116dd..4057a7e12 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -43,6 +43,19 @@ export class DataService { private settingsStorageService: SettingsStorageService ) {} + public createCheckoutSession({ + couponId, + priceId + }: { + couponId?: string; + priceId: string; + }) { + return this.http.post('/api/subscription/stripe/checkout-session', { + couponId, + priceId + }); + } + public fetchAccounts() { return this.http.get('/api/account'); } diff --git a/apps/client/src/environments/environment.prod.ts b/apps/client/src/environments/environment.prod.ts index 41d27ee54..01ed05423 100644 --- a/apps/client/src/environments/environment.prod.ts +++ b/apps/client/src/environments/environment.prod.ts @@ -1,5 +1,6 @@ export const environment = { lastPublish: '{BUILD_TIMESTAMP}', production: true, + stripePublicKey: '{STRIPE_PUBLIC_KEY}', version: `v${require('../../../../package.json').version}` }; diff --git a/apps/client/src/environments/environment.ts b/apps/client/src/environments/environment.ts index 488a3b58e..58a0fe1cf 100644 --- a/apps/client/src/environments/environment.ts +++ b/apps/client/src/environments/environment.ts @@ -5,6 +5,7 @@ export const environment = { lastPublish: null, production: false, + stripePublicKey: '', version: 'dev' }; diff --git a/libs/common/src/lib/interfaces/info-item.interface.ts b/libs/common/src/lib/interfaces/info-item.interface.ts index cef64e5f6..e8a794840 100644 --- a/libs/common/src/lib/interfaces/info-item.interface.ts +++ b/libs/common/src/lib/interfaces/info-item.interface.ts @@ -1,6 +1,7 @@ import { Currency } from '@prisma/client'; import { Statistics } from './statistics.interface'; +import { Subscription } from './subscription.interface'; export interface InfoItem { currencies: Currency[]; @@ -13,4 +14,5 @@ export interface InfoItem { }; platforms: { id: string; name: string }[]; statistics: Statistics; + subscriptions: Subscription[]; } diff --git a/libs/common/src/lib/interfaces/subscription.interface.ts b/libs/common/src/lib/interfaces/subscription.interface.ts new file mode 100644 index 000000000..29f5d3aba --- /dev/null +++ b/libs/common/src/lib/interfaces/subscription.interface.ts @@ -0,0 +1,6 @@ +export interface Subscription { + coupon?: number; + couponId?: string; + price: number; + priceId: string; +} diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts index 01d3aa5be..a63394e0d 100644 --- a/libs/common/src/lib/interfaces/user-settings.interface.ts +++ b/libs/common/src/lib/interfaces/user-settings.interface.ts @@ -1,7 +1,7 @@ import { Currency, ViewMode } from '@prisma/client'; export interface UserSettings { - baseCurrency: Currency; + baseCurrency?: Currency; locale: string; - viewMode: ViewMode; + viewMode?: ViewMode; } diff --git a/libs/common/src/lib/interfaces/user-with-settings.ts b/libs/common/src/lib/interfaces/user-with-settings.ts index eae7789ad..8c6fce1fb 100644 --- a/libs/common/src/lib/interfaces/user-with-settings.ts +++ b/libs/common/src/lib/interfaces/user-with-settings.ts @@ -3,6 +3,7 @@ import { Account, Settings, User } from '@prisma/client'; export type UserWithSettings = User & { Account: Account[]; + permissions?: string[]; Settings: Settings; subscription?: { expiresAt?: Date; diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 4d8f3a719..fe6c0d066 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -21,7 +21,8 @@ export const permissions = { updateAccount: 'updateAccount', updateAuthDevice: 'updateAuthDevice', updateOrder: 'updateOrder', - updateUserSettings: 'updateUserSettings' + updateUserSettings: 'updateUserSettings', + updateViewMode: 'updateViewMode' }; export function hasPermission( @@ -46,7 +47,8 @@ export function getPermissions(aRole: Role): string[] { permissions.updateAccount, permissions.updateAuthDevice, permissions.updateOrder, - permissions.updateUserSettings + permissions.updateUserSettings, + permissions.updateViewMode ]; case 'DEMO': @@ -62,7 +64,8 @@ export function getPermissions(aRole: Role): string[] { permissions.updateAccount, permissions.updateAuthDevice, permissions.updateOrder, - permissions.updateUserSettings + permissions.updateUserSettings, + permissions.updateViewMode ]; default: diff --git a/package.json b/package.json index 01a0f42ad..e7a203936 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "affected:lint": "nx affected:lint", "affected:test": "nx affected:test", "angular": "node --max_old_space_size=32768 ./node_modules/@angular/cli/bin/ng", - "build:all": "ng build --prod api && ng build --prod client && yarn replace-placeholders-in-build", + "build:all": "ng build --configuration production api && ng build --configuration production client && yarn replace-placeholders-in-build", "clean": "rimraf dist", "database:format-schema": "prisma format", "database:generate-typings": "prisma generate", @@ -69,6 +69,7 @@ "@simplewebauthn/browser": "3.0.0", "@simplewebauthn/server": "3.0.0", "@simplewebauthn/typescript-types": "3.0.0", + "@stripe/stripe-js": "1.15.0", "@types/lodash": "4.14.168", "alphavantage": "2.2.0", "angular-material-css-vars": "1.2.0", @@ -92,6 +93,7 @@ "ngx-device-detector": "2.1.1", "ngx-markdown": "12.0.1", "ngx-skeleton-loader": "2.9.1", + "ngx-stripe": "12.0.2", "passport": "0.4.1", "passport-google-oauth20": "2.0.0", "passport-jwt": "4.0.0", @@ -99,6 +101,7 @@ "reflect-metadata": "0.1.13", "round-to": "5.0.0", "rxjs": "6.6.7", + "stripe": "8.156.0", "svgmap": "2.1.1", "uuid": "8.3.2", "yahoo-finance": "0.3.6", @@ -129,6 +132,7 @@ "@typescript-eslint/parser": "4.27.0", "codelyzer": "6.0.1", "cypress": "6.2.1", + "dotenv": "8.2.0", "eslint": "7.28.0", "eslint-config-prettier": "8.3.0", "eslint-plugin-import": "2.23.4", diff --git a/replace.build.js b/replace.build.js index 2e4afd9ed..90cc3ff5c 100644 --- a/replace.build.js +++ b/replace.build.js @@ -1,4 +1,11 @@ +const dotenv = require('dotenv'); +const path = require('path'); const replace = require('replace-in-file'); + +dotenv.config({ + path: path.resolve(__dirname, '.env') +}); + const now = new Date(); const buildTimestamp = `${formatWithTwoDigits( now.getDate() @@ -7,17 +14,24 @@ const buildTimestamp = `${formatWithTwoDigits( )}.${now.getFullYear()} ${formatWithTwoDigits( now.getHours() )}:${formatWithTwoDigits(now.getMinutes())}`; -const options = { - files: './dist/apps/client/main.*.js', - from: /{BUILD_TIMESTAMP}/g, - to: buildTimestamp, - allowEmptyPaths: false -}; try { - const changedFiles = replace.sync(options); + let changedFiles = replace.sync({ + files: './dist/apps/client/main.*.js', + from: /{BUILD_TIMESTAMP}/g, + to: buildTimestamp, + allowEmptyPaths: false + }); console.log('Build version set: ' + buildTimestamp); console.log(changedFiles); + + changedFiles = replace.sync({ + files: './dist/apps/client/main.*.js', + from: /{STRIPE_PUBLIC_KEY}/g, + to: process.env.STRIPE_PUBLIC_KEY ?? '', + allowEmptyPaths: false + }); + console.log(changedFiles); } catch (error) { console.error('Error occurred:', error); } diff --git a/yarn.lock b/yarn.lock index 0dd28ea5c..12db72e43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2454,6 +2454,11 @@ resolved "https://registry.yarnpkg.com/@stencil/core/-/core-2.5.2.tgz#ad00d495ee37bbed4044524d59c7f22de15ab4a7" integrity sha512-bgjPXkSzzg1WnTgVUm6m5ZzpKt602WmA/QljODAW1xVN40OHJdbGblzF/F6MFzqv2c5Cy30CB41arc8qADIdcQ== +"@stripe/stripe-js@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.15.0.tgz#86178cfbe66151910b09b03595e60048ab4c698e" + integrity sha512-KQsNPc+uVQkc8dewwz1A6uHOWeU2cWoZyNIbsx5mtmperr5TPxw4u8M20WOa22n6zmIOh/zLdzEe8DYK/0IjBw== + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -2656,6 +2661,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.33.tgz#9e4f8c64345522e4e8ce77b334a8aaa64e2b6c78" integrity sha512-oJqcTrgPUF29oUP8AsUqbXGJNuPutsetaa9kTQAQce5Lx5dTYWV02ScBiT/k1BX/Z7pKeqedmvp39Wu4zR7N7g== +"@types/node@>=8.1.0": + version "15.12.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-15.12.3.tgz#2817bf5f25bc82f56579018c53f7d41b1830b1af" + integrity sha512-SNt65CPCXvGNDZ3bvk1TQ0Qxoe3y1RKH88+wZ2Uf05dduBCqqFQ76ADP9pbT+Cpvj60SkRppMCh2Zo8tDixqjQ== + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -9623,6 +9633,13 @@ ngx-skeleton-loader@2.9.1: perf-marks "^1.13.4" tslib "^1.10.0" +ngx-stripe@12.0.2: + version "12.0.2" + resolved "https://registry.yarnpkg.com/ngx-stripe/-/ngx-stripe-12.0.2.tgz#b250acc2a08dc96dac035fc0a67b4a8cbeca3efb" + integrity sha512-/arfIi996yv3EpzqjYsb20TUdQ9t+GVMNVIx1mdsiWcpiNjL36tO3lG45T0hyiBJNAds87Ag40Fm8PfsuHFCUw== + dependencies: + tslib "^2.1.0" + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -11329,6 +11346,13 @@ qs@6.7.0: resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== +qs@^6.6.0: + version "6.10.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a" + integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg== + dependencies: + side-channel "^1.0.4" + qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" @@ -12185,6 +12209,15 @@ shellwords@^0.1.1: resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== +side-channel@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" + integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== + dependencies: + call-bind "^1.0.0" + get-intrinsic "^1.0.2" + object-inspect "^1.9.0" + signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" @@ -12692,6 +12725,14 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +stripe@8.156.0: + version "8.156.0" + resolved "https://registry.yarnpkg.com/stripe/-/stripe-8.156.0.tgz#040de551df88d71ef670a8c8d4df114c3fa6eb4b" + integrity sha512-q+bixlhaxnSI/Htk/iB1i5LhuZ557hL0pFgECBxQNhso1elxIsOsPOIXEuo3tSLJEb8CJSB7t/+Fyq6KP69tAQ== + dependencies: + "@types/node" ">=8.1.0" + qs "^6.6.0" + style-loader@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-2.0.0.tgz#9669602fd4690740eaaec137799a03addbbc393c"