diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f551751..92bcd8123 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). +## 1.88.0 - 09.12.2021 + +### Added + +- Added a coupon system + ## 1.87.0 - 07.12.2021 ### Added diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index ef6753894..1de70e588 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -31,7 +31,6 @@ export class AdminController { public constructor( private readonly adminService: AdminService, private readonly dataGatheringService: DataGatheringService, - private readonly propertyService: PropertyService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 887b1cbe5..0887175f1 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -729,8 +729,8 @@ export class PortfolioService { currentNetPerformance, currentNetPerformancePercent, currentValue, - isAllTimeHigh: true, // TODO - isAllTimeLow: false // TODO + isAllTimeHigh: true, + isAllTimeLow: false } }; } diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index 0eb345f63..9ffb86f2d 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -1,4 +1,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { PROPERTY_COUPONS } from '@ghostfolio/common/config'; +import { Coupon } from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -14,6 +17,7 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +import { Response } from 'express'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { SubscriptionService } from './subscription.service'; @@ -22,13 +26,63 @@ import { SubscriptionService } from './subscription.service'; export class SubscriptionController { public constructor( private readonly configurationService: ConfigurationService, + private readonly propertyService: PropertyService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly subscriptionService: SubscriptionService ) {} + @Post('redeem-coupon') + @UseGuards(AuthGuard('jwt')) + public async redeemCoupon( + @Body() { couponCode }: { couponCode: string }, + @Res() res: Response + ) { + if (!this.request.user) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + let coupons = + ((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ?? + []; + + const isValid = coupons.some((coupon) => { + return coupon.code === couponCode; + }); + + if (!isValid) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + await this.subscriptionService.createSubscription(this.request.user.id); + + // Destroy coupon + coupons = coupons.filter((coupon) => { + return coupon.code !== couponCode; + }); + await this.propertyService.put({ + key: PROPERTY_COUPONS, + value: JSON.stringify(coupons) + }); + + Logger.log(`Coupon with code '${couponCode}' has been redeemed`); + + res.status(StatusCodes.OK); + + return res.json({ + message: getReasonPhrase(StatusCodes.OK), + statusCode: StatusCodes.OK + }); + } + @Get('stripe/callback') public async stripeCallback(@Req() req, @Res() res) { - await this.subscriptionService.createSubscription( + await this.subscriptionService.createSubscriptionViaStripe( req.query.checkoutSessionId ); diff --git a/apps/api/src/app/subscription/subscription.module.ts b/apps/api/src/app/subscription/subscription.module.ts index 48671550c..95d16fb4d 100644 --- a/apps/api/src/app/subscription/subscription.module.ts +++ b/apps/api/src/app/subscription/subscription.module.ts @@ -1,12 +1,13 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { Module } from '@nestjs/common'; import { SubscriptionController } from './subscription.controller'; import { SubscriptionService } from './subscription.service'; @Module({ - imports: [], + imports: [PropertyModule], controllers: [SubscriptionController], providers: [ConfigurationService, PrismaService, SubscriptionService], exports: [SubscriptionService] diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index 2d40cbcc2..2c4a81b95 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -2,7 +2,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { Injectable, Logger } from '@nestjs/common'; -import { Subscription } from '@prisma/client'; +import { Subscription, User } from '@prisma/client'; import { addDays, isBefore } from 'date-fns'; import Stripe from 'stripe'; @@ -64,22 +64,28 @@ export class SubscriptionService { }; } - public async createSubscription(aCheckoutSessionId: string) { + public async createSubscription(aUserId: string) { + await this.prismaService.subscription.create({ + data: { + expiresAt: addDays(new Date(), 365), + User: { + connect: { + id: aUserId + } + } + } + }); + + Logger.log(`Subscription for user '${aUserId}' has been created`); + } + + public async createSubscriptionViaStripe(aCheckoutSessionId: string) { try { const session = await this.stripe.checkout.sessions.retrieve( aCheckoutSessionId ); - await this.prismaService.subscription.create({ - data: { - expiresAt: addDays(new Date(), 365), - User: { - connect: { - id: session.client_reference_id - } - } - } - }); + await this.createSubscription(session.client_reference_id); await this.stripe.customers.update(session.customer as string, { description: session.client_reference_id diff --git a/apps/client/src/app/components/admin-overview/admin-overview.component.ts b/apps/client/src/app/components/admin-overview/admin-overview.component.ts index 51105632b..dc15cca12 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.component.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.component.ts @@ -6,11 +6,12 @@ import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_DATE_FORMAT, + PROPERTY_COUPONS, PROPERTY_CURRENCIES, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_SYSTEM_MESSAGE } from '@ghostfolio/common/config'; -import { InfoItem, User } from '@ghostfolio/common/interfaces'; +import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { differenceInSeconds, @@ -28,11 +29,13 @@ import { takeUntil } from 'rxjs/operators'; templateUrl: './admin-overview.html' }) export class AdminOverviewComponent implements OnDestroy, OnInit { + public coupons: Coupon[]; public customCurrencies: string[]; public dataGatheringInProgress: boolean; public dataGatheringProgress: number; public defaultDateFormat = DEFAULT_DATE_FORMAT; public exchangeRates: { label1: string; label2: string; value: number }[]; + public hasPermissionForSubscription: boolean; public hasPermissionForSystemMessage: boolean; public hasPermissionToToggleReadOnlyMode: boolean; public info: InfoItem; @@ -61,6 +64,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { if (state?.user) { this.user = state.user; + this.hasPermissionForSubscription = hasPermission( + this.info.globalPermissions, + permissions.enableSubscription + ); + this.hasPermissionForSystemMessage = hasPermission( this.info.globalPermissions, permissions.enableSystemMessage @@ -96,6 +104,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { return ''; } + public onAddCoupon() { + const coupons = [...this.coupons, { code: this.generateCouponCode(16) }]; + this.putCoupons(coupons); + } + public onAddCurrency() { const currency = prompt('Please add a currency:'); @@ -105,6 +118,17 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { } } + public onDeleteCoupon(aCouponCode: string) { + const confirmation = confirm('Do you really want to delete this coupon?'); + + if (confirmation) { + const coupons = this.coupons.filter((coupon) => { + return coupon.code !== aCouponCode; + }); + this.putCoupons(coupons); + } + } + public onDeleteCurrency(aCurrency: string) { const confirmation = confirm('Do you really want to delete this currency?'); @@ -185,6 +209,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { transactionCount, userCount }) => { + this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? []; this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[]; this.dataGatheringProgress = dataGatheringProgress; this.exchangeRates = exchangeRates; @@ -210,6 +235,32 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { ); } + private generateCouponCode(aLength: number) { + const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ123456789'; + let couponCode = ''; + + for (let i = 0; i < aLength; i++) { + couponCode += characters.charAt( + Math.floor(Math.random() * characters.length) + ); + } + + return couponCode; + } + + private putCoupons(aCoupons: Coupon[]) { + this.dataService + .putAdminSetting(PROPERTY_COUPONS, { + value: JSON.stringify(aCoupons) + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + setTimeout(() => { + window.location.reload(); + }, 300); + }); + } + private putCurrencies(aCurrencies: string[]) { this.dataService .putAdminSetting(PROPERTY_CURRENCIES, { diff --git a/apps/client/src/app/components/admin-overview/admin-overview.html b/apps/client/src/app/components/admin-overview/admin-overview.html index 80bbf1ab6..04f8ba4ec 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.html +++ b/apps/client/src/app/components/admin-overview/admin-overview.html @@ -142,7 +142,7 @@ class="mr-1" name="information-circle-outline" > - Set System Message + Set Message @@ -156,6 +156,27 @@ > +
+
Coupons
+
+
+ {{ coupon.code }} + +
+
+ +
+
+
diff --git a/apps/client/src/app/core/http-response.interceptor.ts b/apps/client/src/app/core/http-response.interceptor.ts index ea0cf8d2e..1df29e7ec 100644 --- a/apps/client/src/app/core/http-response.interceptor.ts +++ b/apps/client/src/app/core/http-response.interceptor.ts @@ -14,15 +14,14 @@ import { TextOnlySnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { InfoItem } from '@ghostfolio/common/interfaces'; import { StatusCodes } from 'http-status-codes'; import { Observable, throwError } from 'rxjs'; import { catchError, tap } from 'rxjs/operators'; -import { DataService } from '@ghostfolio/client/services/data.service'; -import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; - @Injectable() export class HttpResponseInterceptor implements HttpInterceptor { public info: InfoItem; 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 244c57cce..5d1d0d5e3 100644 --- a/apps/client/src/app/pages/account/account-page.component.ts +++ b/apps/client/src/app/pages/account/account-page.component.ts @@ -10,6 +10,11 @@ import { MatSlideToggle, MatSlideToggleChange } from '@angular/material/slide-toggle'; +import { + MatSnackBar, + MatSnackBarRef, + TextOnlySnackBar +} from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; import { DataService } from '@ghostfolio/client/services/data.service'; @@ -49,6 +54,7 @@ export class AccountPageComponent implements OnDestroy, OnInit { public hasPermissionToUpdateUserSettings: boolean; public price: number; public priceId: string; + public snackBarRef: MatSnackBarRef; public user: User; private unsubscribeSubject = new Subject(); @@ -61,6 +67,7 @@ export class AccountPageComponent implements OnDestroy, OnInit { private dataService: DataService, private deviceService: DeviceDetectorService, private dialog: MatDialog, + private snackBar: MatSnackBar, private route: ActivatedRoute, private router: Router, private stripeService: StripeService, @@ -185,6 +192,49 @@ export class AccountPageComponent implements OnDestroy, OnInit { }); } + public onRedeemCoupon() { + let couponCode = prompt('Please enter your coupon code:'); + couponCode = couponCode?.trim(); + + if (couponCode) { + this.dataService + .redeemCoupon(couponCode) + .pipe( + takeUntil(this.unsubscribeSubject), + catchError(() => { + this.snackBar.open('😞 Could not redeem coupon code', undefined, { + duration: 3000 + }); + + return EMPTY; + }) + ) + .subscribe(() => { + this.snackBarRef = this.snackBar.open( + '✅ Coupon code has been redeemed', + 'Reload', + { + duration: 3000 + } + ); + + this.snackBarRef + .afterDismissed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + window.location.reload(); + }); + + this.snackBarRef + .onAction() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + window.location.reload(); + }); + }); + } + } + public onRestrictedViewChange(aEvent: MatSlideToggleChange) { this.dataService .putUserSetting({ isRestrictedView: aEvent.checked }) diff --git a/apps/client/src/app/pages/account/account-page.html b/apps/client/src/app/pages/account/account-page.html index 9ffd2159d..1155741f9 100644 --- a/apps/client/src/app/pages/account/account-page.html +++ b/apps/client/src/app/pages/account/account-page.html @@ -47,6 +47,13 @@ {{ price }} per year + Redeem Coupon diff --git a/apps/client/src/app/pages/account/account-page.module.ts b/apps/client/src/app/pages/account/account-page.module.ts index 5b3d8ce37..cf0f52a03 100644 --- a/apps/client/src/app/pages/account/account-page.module.ts +++ b/apps/client/src/app/pages/account/account-page.module.ts @@ -8,6 +8,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { RouterModule } from '@angular/router'; import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module'; import { AccountPageRoutingModule } from './account-page-routing.module'; @@ -30,7 +31,8 @@ import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-di MatInputModule, MatSelectModule, MatSlideToggleModule, - ReactiveFormsModule + ReactiveFormsModule, + RouterModule ], providers: [] }) diff --git a/apps/client/src/app/pages/account/account-page.scss b/apps/client/src/app/pages/account/account-page.scss index 4a798522a..bafd8d6e4 100644 --- a/apps/client/src/app/pages/account/account-page.scss +++ b/apps/client/src/app/pages/account/account-page.scss @@ -2,6 +2,15 @@ color: rgb(var(--dark-primary-text)); display: block; + a { + color: rgba(var(--palette-primary-500), 1); + font-weight: 500; + + &:hover { + color: rgba(var(--palette-primary-300), 1); + } + } + gf-access-table { overflow-x: auto; diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index b15dd1723..9ba5ca669 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -253,4 +253,10 @@ export class DataService { public putUserSettings(aData: UpdateUserSettingsDto) { return this.http.put(`/api/user/settings`, aData); } + + public redeemCoupon(couponCode: string) { + return this.http.post('/api/subscription/redeem-coupon', { + couponCode + }); + } } diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 31076cccd..d9acd3e0b 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -30,6 +30,7 @@ export const warnColorRgb = { export const DEFAULT_DATE_FORMAT = 'dd.MM.yyyy'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; +export const PROPERTY_COUPONS = 'COUPONS'; export const PROPERTY_CURRENCIES = 'CURRENCIES'; export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; export const PROPERTY_LAST_DATA_GATHERING = 'LAST_DATA_GATHERING'; diff --git a/libs/common/src/lib/interfaces/coupon.interface.ts b/libs/common/src/lib/interfaces/coupon.interface.ts new file mode 100644 index 000000000..3caa218e6 --- /dev/null +++ b/libs/common/src/lib/interfaces/coupon.interface.ts @@ -0,0 +1,3 @@ +export interface Coupon { + code: string; +} diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 3192ece9f..d9bcc3a8b 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -3,6 +3,7 @@ import { Accounts } from './accounts.interface'; import { AdminData } from './admin-data.interface'; import { AdminMarketDataDetails } from './admin-market-data-details.interface'; import { AdminMarketData } from './admin-market-data.interface'; +import { Coupon } from './coupon.interface'; import { Export } from './export.interface'; import { InfoItem } from './info-item.interface'; import { PortfolioChart } from './portfolio-chart.interface'; @@ -27,6 +28,7 @@ export { AdminData, AdminMarketData, AdminMarketDataDetails, + Coupon, Export, InfoItem, PortfolioChart, diff --git a/package.json b/package.json index b24789407..cb98574e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "1.87.0", + "version": "1.88.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "scripts": {