diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index 8dbd54ee9..9ffb86f2d 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -44,7 +44,7 @@ export class SubscriptionController { ); } - const coupons = + let coupons = ((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ?? []; @@ -59,7 +59,18 @@ export class SubscriptionController { ); } - // TODO: Add subscription + 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); @@ -71,7 +82,7 @@ export class SubscriptionController { @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.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 178f46a7b..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 @@ -236,7 +236,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { } private generateCouponCode(aLength: number) { - const characters = 'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789'; + const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ123456789'; let couponCode = ''; for (let i = 0; i < aLength; i++) { 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 779e5068f..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'; @@ -18,7 +23,6 @@ import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config'; import { Access, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { StatusCodes } from 'http-status-codes'; import { DeviceDetectorService } from 'ngx-device-detector'; import { StripeService } from 'ngx-stripe'; import { EMPTY, Subject } from 'rxjs'; @@ -50,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(); @@ -62,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, @@ -187,7 +193,8 @@ export class AccountPageComponent implements OnDestroy, OnInit { } public onRedeemCoupon() { - const couponCode = prompt('Please add your coupon code:'); + let couponCode = prompt('Please enter your coupon code:'); + couponCode = couponCode?.trim(); if (couponCode) { this.dataService @@ -195,13 +202,35 @@ export class AccountPageComponent implements OnDestroy, OnInit { .pipe( takeUntil(this.unsubscribeSubject), catchError(() => { - // TODO: show error notification + this.snackBar.open('😞 Could not redeem coupon code', undefined, { + duration: 3000 + }); return EMPTY; }) ) .subscribe(() => { - // TODO: show success notification + 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(); + }); }); } } diff --git a/apps/client/src/app/pages/account/account-page.html b/apps/client/src/app/pages/account/account-page.html index dba3c366f..1155741f9 100644 --- a/apps/client/src/app/pages/account/account-page.html +++ b/apps/client/src/app/pages/account/account-page.html @@ -47,14 +47,13 @@ {{ price }} per year - 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;