From 48bff89526e345a8793628ad72167eab3b644449 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Tue, 20 Aug 2024 23:27:07 +0200 Subject: [PATCH] Stripe Integration --- bizmatch-server/package.json | 5 +- bizmatch-server/src/ai/ai.service.ts | 4 +- bizmatch-server/src/app.module.ts | 2 + bizmatch-server/src/drizzle/import.ts | 2 +- bizmatch-server/src/drizzle/schema.ts | 6 + bizmatch-server/src/mail/mail.service.ts | 17 +- .../templates/subscriptionConfirmation.hbs | 77 +++++++++ bizmatch-server/src/main.ts | 2 + bizmatch-server/src/models/db.model.ts | 6 + bizmatch-server/src/models/main.model.ts | 11 +- .../src/payment/payment.controller.ts | 36 ++++ bizmatch-server/src/payment/payment.module.ts | 17 ++ .../src/payment/payment.service.ts | 81 +++++++++ bizmatch-server/src/user/user.service.ts | 2 +- bizmatch/package.json | 4 +- bizmatch/src/app/app.config.ts | 2 + bizmatch/src/app/app.routes.ts | 14 ++ .../components/payment/payment.component.html | 161 ++++++++++++++++++ .../components/payment/payment.component.ts | 75 ++++++++ .../app/components/payment/payment.service.ts | 32 ++++ .../src/app/pages/home/home.component.html | 2 +- .../app/pages/pricing/pricing.component.html | 16 +- .../app/pages/pricing/pricing.component.ts | 43 ++++- .../subscription/account/account.component.ts | 2 +- .../app/pages/success/success.component.html | 31 ++++ .../app/pages/success/success.component.scss | 33 ++++ .../app/pages/success/success.component.ts | 62 +++++++ bizmatch/src/styles.scss | 4 + 28 files changed, 728 insertions(+), 21 deletions(-) create mode 100644 bizmatch-server/src/mail/templates/subscriptionConfirmation.hbs create mode 100644 bizmatch-server/src/payment/payment.controller.ts create mode 100644 bizmatch-server/src/payment/payment.module.ts create mode 100644 bizmatch-server/src/payment/payment.service.ts create mode 100644 bizmatch/src/app/components/payment/payment.component.html create mode 100644 bizmatch/src/app/components/payment/payment.component.ts create mode 100644 bizmatch/src/app/components/payment/payment.service.ts create mode 100644 bizmatch/src/app/pages/success/success.component.html create mode 100644 bizmatch/src/app/pages/success/success.component.scss create mode 100644 bizmatch/src/app/pages/success/success.component.ts diff --git a/bizmatch-server/package.json b/bizmatch-server/package.json index f578521..21a8b10 100644 --- a/bizmatch-server/package.json +++ b/bizmatch-server/package.json @@ -34,6 +34,8 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/serve-static": "^4.0.1", + "@types/stripe": "^8.0.417", + "body-parser": "^1.20.2", "cors": "^2.8.5", "dotenv": "^16.4.5", "drizzle-orm": "^0.32.0", @@ -55,6 +57,7 @@ "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sharp": "^0.33.2", + "stripe": "^16.8.0", "tsx": "^4.16.2", "urlcat": "^3.1.0", "winston": "^3.11.0", @@ -114,4 +117,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} \ No newline at end of file +} diff --git a/bizmatch-server/src/ai/ai.service.ts b/bizmatch-server/src/ai/ai.service.ts index 01abffb..ade9f75 100644 --- a/bizmatch-server/src/ai/ai.service.ts +++ b/bizmatch-server/src/ai/ai.service.ts @@ -68,7 +68,7 @@ export class AiService { role: 'system', content: `Please create unformatted JSON Object from a user input. The type must be: ${JSON.stringify(businessListingCriteriaStructure)}. - If location details available please fill city, county and state as State Code`, + If location details available please fill city and state as State Code and only county if explicitly mentioned`, }, { role: 'user', @@ -77,6 +77,8 @@ export class AiService { ], model: 'llama-3.1-70b-versatile', //model: 'llama-3.1-8b-instant', + // model: 'mixtral-8x7b-32768', + //model: 'gemma2-9b-it', temperature: 0.2, max_tokens: 300, response_format: { type: 'json_object' }, diff --git a/bizmatch-server/src/app.module.ts b/bizmatch-server/src/app.module.ts index 9d7a82c..e90d720 100644 --- a/bizmatch-server/src/app.module.ts +++ b/bizmatch-server/src/app.module.ts @@ -17,6 +17,7 @@ import { LogController } from './log/log.controller.js'; import { LogModule } from './log/log.module.js'; import { MailModule } from './mail/mail.module.js'; +import { PaymentModule } from './payment/payment.module.js'; import { RequestDurationMiddleware } from './request-duration/request-duration.middleware.js'; import { SelectOptionsModule } from './select-options/select-options.module.js'; import { UserModule } from './user/user.module.js'; @@ -79,6 +80,7 @@ loadEnvFiles(); PassportModule, AiModule, LogModule, + PaymentModule, ], controllers: [AppController, LogController], providers: [AppService, FileService], diff --git a/bizmatch-server/src/drizzle/import.ts b/bizmatch-server/src/drizzle/import.ts index 20f8d30..b6cb778 100644 --- a/bizmatch-server/src/drizzle/import.ts +++ b/bizmatch-server/src/drizzle/import.ts @@ -115,7 +115,7 @@ fs.ensureDirSync(`./pictures/property`); //User for (let index = 0; index < usersData.length; index++) { const userData = usersData[index]; - const user: User = createDefaultUser('', '', ''); + const user: User = createDefaultUser('', '', '', null); user.licensedIn = []; userData.licensedIn.forEach(l => { console.log(l['value'], l['name']); diff --git a/bizmatch-server/src/drizzle/schema.ts b/bizmatch-server/src/drizzle/schema.ts index daef241..a7180dc 100644 --- a/bizmatch-server/src/drizzle/schema.ts +++ b/bizmatch-server/src/drizzle/schema.ts @@ -5,6 +5,7 @@ export const genderEnum = pgEnum('gender', ['male', 'female']); export const customerTypeEnum = pgEnum('customerType', ['buyer', 'professional']); export const customerSubTypeEnum = pgEnum('customerSubType', ['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']); export const listingsCategoryEnum = pgEnum('listingsCategory', ['commercialProperty', 'business']); +export const subscriptionTypeEnum = pgEnum('subscriptionType', ['free', 'professional', 'broker']); export const users = pgTable('users', { id: uuid('id').primaryKey().defaultRandom().notNull(), @@ -30,6 +31,11 @@ export const users = pgTable('users', { updated: timestamp('updated'), latitude: doublePrecision('latitude'), longitude: doublePrecision('longitude'), + stripeCustomerId: text('stripeCustomerId'), + subscriptionId: text('subscriptionId'), + planActive: boolean('planActive').default(false), + planExpires: timestamp('planExpires'), + subscriptionPlan: subscriptionTypeEnum('subscriptionType'), // embedding: vector('embedding', { dimensions: 1536 }), }); diff --git a/bizmatch-server/src/mail/mail.service.ts b/bizmatch-server/src/mail/mail.service.ts index 0f13d3e..03c1eb9 100644 --- a/bizmatch-server/src/mail/mail.service.ts +++ b/bizmatch-server/src/mail/mail.service.ts @@ -3,7 +3,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import path, { join } from 'path'; import { fileURLToPath } from 'url'; import { ZodError } from 'zod'; -import { SenderSchema } from '../models/db.model.js'; +import { SenderSchema, User } from '../models/db.model.js'; import { ErrorResponse, MailInfo, isEmpty } from '../models/main.model.js'; import { UserService } from '../user/user.service.js'; const __filename = fileURLToPath(import.meta.url); @@ -81,4 +81,19 @@ export class MailService { }, }); } + async sendSubscriptionConfirmation(user: User): Promise { + await this.mailerService.sendMail({ + to: 'support@bizmatch.net', + from: `"Bizmatch Support Team" `, + subject: `Subscription Confirmation`, + //template: './inquiry', // `.hbs` extension is appended automatically + template: join(__dirname, '../..', 'mail/templates/subscriptionConfirmation.hbs'), + context: { + // ✏️ filling curly brackets with content + firstname: user.firstname, + lastname: user.lastname, + subscriptionPlan: user.subscriptionPlan, + }, + }); + } } diff --git a/bizmatch-server/src/mail/templates/subscriptionConfirmation.hbs b/bizmatch-server/src/mail/templates/subscriptionConfirmation.hbs new file mode 100644 index 0000000..b0d7ed5 --- /dev/null +++ b/bizmatch-server/src/mail/templates/subscriptionConfirmation.hbs @@ -0,0 +1,77 @@ + + + + + + Subscription Confirmation + + + + + + diff --git a/bizmatch-server/src/main.ts b/bizmatch-server/src/main.ts index 255db8c..d3f9216 100644 --- a/bizmatch-server/src/main.ts +++ b/bizmatch-server/src/main.ts @@ -1,10 +1,12 @@ import { NestFactory } from '@nestjs/core'; +import bodyParser from 'body-parser'; import express from 'express'; import { AppModule } from './app.module.js'; async function bootstrap() { const server = express(); const app = await NestFactory.create(AppModule); + app.use('/bizmatch/payment/webhook', bodyParser.raw({ type: 'application/json' })); app.setGlobalPrefix('bizmatch'); app.enableCors({ origin: '*', diff --git a/bizmatch-server/src/models/db.model.ts b/bizmatch-server/src/models/db.model.ts index 4ef9ecd..2c6e693 100644 --- a/bizmatch-server/src/models/db.model.ts +++ b/bizmatch-server/src/models/db.model.ts @@ -29,6 +29,7 @@ export type ListingsCategory = 'commercialProperty' | 'business'; export const GenderEnum = z.enum(['male', 'female']); export const CustomerTypeEnum = z.enum(['buyer', 'professional']); +export const SubscriptionTypeEnum = z.enum(['free', 'professional', 'broker']); export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']); export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']); const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']); @@ -156,6 +157,11 @@ export const UserSchema = z customerSubType: CustomerSubTypeEnum.optional().nullable(), created: z.date().optional().nullable(), updated: z.date().optional().nullable(), + stripeCustomerId: z.string().optional().nullable(), + subscriptionId: z.string().optional().nullable(), + planActive: z.boolean().optional().nullable(), + planExpires: z.date().optional().nullable(), + subscriptionPlan: SubscriptionTypeEnum.optional().nullable(), }) .superRefine((data, ctx) => { if (data.customerType === 'professional') { diff --git a/bizmatch-server/src/models/main.model.ts b/bizmatch-server/src/models/main.model.ts index dc92e44..c52ba5f 100644 --- a/bizmatch-server/src/models/main.model.ts +++ b/bizmatch-server/src/models/main.model.ts @@ -255,6 +255,10 @@ export interface ModalResult { accepted: boolean; criteria?: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria; } +export interface Checkout { + priceId: string; + email: string; +} export function isEmpty(value: any): boolean { // Check for undefined or null if (value === undefined || value === null) { @@ -294,7 +298,7 @@ export interface ValidationMessage { field: string; message: string; } -export function createDefaultUser(email: string, firstname: string, lastname: string): User { +export function createDefaultUser(email: string, firstname: string, lastname: string, subscriptionPlan: 'free' | 'professional' | 'broker'): User { return { id: undefined, email, @@ -316,6 +320,11 @@ export function createDefaultUser(email: string, firstname: string, lastname: st customerSubType: null, created: new Date(), updated: new Date(), + stripeCustomerId: null, + subscriptionId: null, + planActive: false, + planExpires: null, + subscriptionPlan: subscriptionPlan, }; } export function createDefaultCommercialPropertyListing(): CommercialPropertyListing { diff --git a/bizmatch-server/src/payment/payment.controller.ts b/bizmatch-server/src/payment/payment.controller.ts new file mode 100644 index 0000000..1b7975c --- /dev/null +++ b/bizmatch-server/src/payment/payment.controller.ts @@ -0,0 +1,36 @@ +import { Body, Controller, HttpException, HttpStatus, Post, Req, Res } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { Checkout } from 'src/models/main.model.js'; +import Stripe from 'stripe'; +import { PaymentService } from './payment.service.js'; + +@Controller('payment') +export class PaymentController { + constructor(private readonly paymentService: PaymentService) {} + + // @Post() + // async createSubscription(@Body() subscriptionData: any) { + // return this.paymentService.createSubscription(subscriptionData); + // } + @Post('create-checkout-session') + async calculateTax(@Body() checkout: Checkout) { + return this.paymentService.checkout(checkout); + } + @Post('webhook') + async handleWebhook(@Req() req: Request, @Res() res: Response): Promise { + const signature = req.headers['stripe-signature'] as string; + + try { + const event = await this.paymentService.constructEvent(req.body, signature); + + if (event.type === 'checkout.session.completed') { + await this.paymentService.handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session); + } + + res.status(200).send('Webhook received'); + } catch (error) { + console.error(`Webhook Error: ${error.message}`); + throw new HttpException('Webhook Error', HttpStatus.BAD_REQUEST); + } + } +} diff --git a/bizmatch-server/src/payment/payment.module.ts b/bizmatch-server/src/payment/payment.module.ts new file mode 100644 index 0000000..1a1ce57 --- /dev/null +++ b/bizmatch-server/src/payment/payment.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { DrizzleModule } from '../drizzle/drizzle.module.js'; +import { FileService } from '../file/file.service.js'; +import { GeoService } from '../geo/geo.service.js'; +import { MailModule } from '../mail/mail.module.js'; +import { MailService } from '../mail/mail.service.js'; +import { UserModule } from '../user/user.module.js'; +import { UserService } from '../user/user.service.js'; +import { PaymentController } from './payment.controller.js'; +import { PaymentService } from './payment.service.js'; + +@Module({ + imports: [DrizzleModule, UserModule, MailModule], + providers: [PaymentService, UserService, MailService, FileService, GeoService], + controllers: [PaymentController], +}) +export class PaymentModule {} diff --git a/bizmatch-server/src/payment/payment.service.ts b/bizmatch-server/src/payment/payment.service.ts new file mode 100644 index 0000000..e384b7f --- /dev/null +++ b/bizmatch-server/src/payment/payment.service.ts @@ -0,0 +1,81 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver.js'; +import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; +import Stripe from 'stripe'; +import { Logger } from 'winston'; +import * as schema from '../drizzle/schema.js'; +import { PG_CONNECTION } from '../drizzle/schema.js'; +import { MailService } from '../mail/mail.service.js'; +import { Checkout } from '../models/main.model.js'; +import { UserService } from '../user/user.service.js'; +export interface BillingAddress { + country: string; + state: string; +} +@Injectable() +export class PaymentService { + private stripe: Stripe; + + constructor( + @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, + @Inject(PG_CONNECTION) private conn: NodePgDatabase, + private readonly userService: UserService, + private readonly mailService: MailService, + ) { + this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2024-06-20', + }); + } + async checkout(checkout: Checkout) { + try { + const session = await this.stripe.checkout.sessions.create({ + mode: 'subscription', + payment_method_types: ['card'], + line_items: [ + { + price: checkout.priceId, + quantity: 1, + }, + ], + success_url: 'http://localhost:4200/success', + cancel_url: 'http://localhost:4200/pricing', + customer_email: checkout.email, + shipping_address_collection: { + allowed_countries: ['US'], + }, + client_reference_id: '1234', + locale: 'en', + }); + + return session; + } catch (e) { + throw new BadRequestException(`error during checkout: ${e}`); + } + } + async constructEvent(body: string | Buffer, signature: string) { + return this.stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!); + } + async handleCheckoutSessionCompleted(session: Stripe.Checkout.Session): Promise { + this.logger.info(JSON.stringify(session)); + //const jwtUser:JwtUser = {firstname:,lastname:} + const user = await this.userService.getUserByMail(session.customer_details.email); + user.stripeCustomerId = session.customer as string; + user.subscriptionId = session.subscription as string; + user.planActive = true; + //session.shipping_details -> + // "shipping_details": { + // "address": { + // "city": "Springfield", + // "country": "US", + // "line1": "West Maple Avenue South", + // "line2": null, + // "postal_code": "62704", + // "state": "IL" + // }, + // "name": "Johnathan Miller" + // } + user.subscriptionPlan = session.amount_total === 4900 ? 'broker' : 'professional'; //session.metadata['subscriptionPlan'] as 'free' | 'professional' | 'broker'; + this.userService.saveUser(user); + this.mailService.sendSubscriptionConfirmation(user); + } +} diff --git a/bizmatch-server/src/user/user.service.ts b/bizmatch-server/src/user/user.service.ts index fdb9f23..912d44f 100644 --- a/bizmatch-server/src/user/user.service.ts +++ b/bizmatch-server/src/user/user.service.ts @@ -96,7 +96,7 @@ export class UserService { .from(schema.users) .where(sql`email = ${email}`)) as User[]; if (users.length === 0) { - const user: User = { id: undefined, customerType: 'buyer', ...createDefaultUser(email, jwtuser.firstname, jwtuser.lastname) }; + const user: User = { id: undefined, customerType: 'buyer', ...createDefaultUser(email, jwtuser.firstname, jwtuser.lastname, 'free') }; const u = await this.saveUser(user); return convertDrizzleUserToUser(u); } else { diff --git a/bizmatch/package.json b/bizmatch/package.json index 96707c8..7bc9866 100644 --- a/bizmatch/package.json +++ b/bizmatch/package.json @@ -31,6 +31,7 @@ "@fortawesome/free-solid-svg-icons": "^6.5.2", "@ng-select/ng-select": "^13.4.1", "@ngneat/until-destroy": "^10.0.0", + "@stripe/stripe-js": "^4.3.0", "@types/cropperjs": "^1.3.0", "@types/uuid": "^10.0.0", "browser-bunyan": "^1.8.0", @@ -45,6 +46,7 @@ "ngx-image-cropper": "^8.0.0", "ngx-mask": "^18.0.0", "ngx-quill": "^26.0.5", + "ngx-stripe": "^18.1.0", "on-change": "^5.0.1", "rxjs": "~7.8.1", "tslib": "^2.6.3", @@ -71,4 +73,4 @@ "tailwindcss": "^3.4.4", "typescript": "~5.4.5" } -} +} \ No newline at end of file diff --git a/bizmatch/src/app/app.config.ts b/bizmatch/src/app/app.config.ts index 414ee50..2c092bb 100644 --- a/bizmatch/src/app/app.config.ts +++ b/bizmatch/src/app/app.config.ts @@ -5,6 +5,7 @@ import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@a import { provideAnimations } from '@angular/platform-browser/animations'; import { KeycloakBearerInterceptor, KeycloakService } from 'keycloak-angular'; import { provideQuillConfig } from 'ngx-quill'; +import { provideNgxStripe } from 'ngx-stripe'; import { environment } from '../environments/environment'; import { customKeycloakAdapter } from '../keycloak'; import { routes } from './app.routes'; @@ -52,6 +53,7 @@ export const appConfig: ApplicationConfig = { }), ), provideAnimations(), + provideNgxStripe('pk_test_IlpbVQhxAXZypLgnCHOCqlj8'), provideQuillConfig({ modules: { syntax: true, diff --git a/bizmatch/src/app/app.routes.ts b/bizmatch/src/app/app.routes.ts index 37e46ab..d37d0cf 100644 --- a/bizmatch/src/app/app.routes.ts +++ b/bizmatch/src/app/app.routes.ts @@ -2,6 +2,7 @@ import { Routes } from '@angular/router'; import { LogoutComponent } from './components/logout/logout.component'; import { NotFoundComponent } from './components/not-found/not-found.component'; +import { PaymentComponent } from './components/payment/payment.component'; import { AuthGuard } from './guards/auth.guard'; import { ListingCategoryGuard } from './guards/listing-category.guard'; import { DetailsBusinessListingComponent } from './pages/details/details-business-listing/details-business-listing.component'; @@ -18,6 +19,7 @@ import { EditCommercialPropertyListingComponent } from './pages/subscription/edi import { EmailUsComponent } from './pages/subscription/email-us/email-us.component'; import { FavoritesComponent } from './pages/subscription/favorites/favorites.component'; import { MyListingComponent } from './pages/subscription/my-listing/my-listing.component'; +import { SuccessComponent } from './pages/success/success.component'; export const routes: Routes = [ { @@ -132,5 +134,17 @@ export const routes: Routes = [ path: 'pricing', component: PricingComponent, }, + { + path: 'pricing/:id', + component: PricingComponent, + }, + { + path: 'payment', + component: PaymentComponent, + }, + { + path: 'success', + component: SuccessComponent, + }, { path: '**', redirectTo: 'home' }, ]; diff --git a/bizmatch/src/app/components/payment/payment.component.html b/bizmatch/src/app/components/payment/payment.component.html new file mode 100644 index 0000000..d901e00 --- /dev/null +++ b/bizmatch/src/app/components/payment/payment.component.html @@ -0,0 +1,161 @@ +
+
+
+

Payment

+ +
+
+
+
+ + +
+ + +
+ + +
+ +
+ +
+ +
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+
+
Subscription Price
+
$49.00
+
+ + + +
+
Tax
+
$1.43
+
+
+ +
+
Total
+
$50.43
+
+
+ +
+ + + + + +
+
+
+ +

+ Payment processed by Stripe for + Bizmatch Inc. + - United States Of America +

+
+
+
diff --git a/bizmatch/src/app/components/payment/payment.component.ts b/bizmatch/src/app/components/payment/payment.component.ts new file mode 100644 index 0000000..6140f24 --- /dev/null +++ b/bizmatch/src/app/components/payment/payment.component.ts @@ -0,0 +1,75 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, ViewChild } from '@angular/core'; +import { FormsModule, NgForm } from '@angular/forms'; +import { StripeCardCvcElementOptions, StripeCardNumberElementOptions, StripeElementsOptions } from '@stripe/stripe-js'; +import { injectStripe, StripeCardComponent, StripeCardCvcComponent, StripeCardExpiryComponent, StripeCardGroupDirective, StripeCardNumberComponent, StripeElementsDirective } from 'ngx-stripe'; +import { PaymentService } from './payment.service'; + +@Component({ + selector: 'app-payment-modal', + templateUrl: './payment.component.html', + standalone: true, + imports: [CommonModule, FormsModule, StripeCardNumberComponent, StripeCardExpiryComponent, StripeCardCvcComponent, StripeElementsDirective, StripeCardGroupDirective], + styles: ` + .ngx-input{ + padding:0.85rem + } + `, +}) +export class PaymentComponent { + @ViewChild('cardElement') cardElement: StripeCardComponent; + stripe = injectStripe('your-stripe-publishable-key'); + name: string; + constructor() {} + cvcOptions: StripeCardCvcElementOptions = { + style: { + base: { + iconColor: '#666EE8', + color: '#111827', + fontWeight: '300', + fontFamily: 'sans-serif', + fontSize: '14px', + '::placeholder': { + color: '#CFD7E0', + // color: '#6B7280', + }, + }, + }, + }; + numberOptions: StripeCardNumberElementOptions = { + showIcon: true, + style: { + base: { + iconColor: '#666EE8', + color: '#111827', + fontWeight: '300', + fontFamily: 'sans-serif', + fontSize: '14px', + '::placeholder': { + color: '#CFD7E0', + // color: '#6B7280', + }, + }, + }, + }; + + elementsOptions: StripeElementsOptions = { + locale: 'en', + }; + + paymentService = inject(PaymentService); + + onSubmit(form: NgForm) { + if (form.valid) { + this.stripe.createToken(this.cardElement.element, { name: this.name }).subscribe(result => { + if (result.token) { + this.paymentService.processPayment(result.token.id).subscribe(() => { + console.log('Payment successful'); + }); + } else if (result.error) { + console.error(result.error.message); + } + }); + } + } +} diff --git a/bizmatch/src/app/components/payment/payment.service.ts b/bizmatch/src/app/components/payment/payment.service.ts new file mode 100644 index 0000000..ce479b6 --- /dev/null +++ b/bizmatch/src/app/components/payment/payment.service.ts @@ -0,0 +1,32 @@ +// 1. Shared Service (modal.service.ts) +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class PaymentService { + private modalVisibleSubject = new BehaviorSubject(false); + modalVisible$: Observable = this.modalVisibleSubject.asObservable(); + private resolvePromise!: (value: boolean) => void; + constructor(private http: HttpClient) {} + openPaymentModal() { + this.modalVisibleSubject.next(true); + return new Promise(resolve => { + this.resolvePromise = resolve; + }); + } + accept(): void { + this.modalVisibleSubject.next(false); + this.resolvePromise(true); + } + + reject(): void { + this.modalVisibleSubject.next(false); + this.resolvePromise(false); + } + processPayment(token: string): Observable { + return this.http.post('/api/subscription', { token }); + } +} diff --git a/bizmatch/src/app/pages/home/home.component.html b/bizmatch/src/app/pages/home/home.component.html index 84bf62c..36e3dec 100644 --- a/bizmatch/src/app/pages/home/home.component.html +++ b/bizmatch/src/app/pages/home/home.component.html @@ -6,7 +6,7 @@ } @else { Pricing Log In - Register + Register } + @@ -51,7 +47,7 @@
  • - Extended public listings (3+ months) + 3-Month Free Trial
  • @@ -76,7 +72,8 @@
    - + +
    @@ -100,7 +97,7 @@
  • - Extended public listings (3+ months) + 3-Month Free Trial
  • @@ -125,7 +122,8 @@
    - + +
    diff --git a/bizmatch/src/app/pages/pricing/pricing.component.ts b/bizmatch/src/app/pages/pricing/pricing.component.ts index b41e54a..703361f 100644 --- a/bizmatch/src/app/pages/pricing/pricing.component.ts +++ b/bizmatch/src/app/pages/pricing/pricing.component.ts @@ -1,5 +1,10 @@ +import { HttpClient } from '@angular/common/http'; import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { KeycloakService } from 'keycloak-angular'; +import { StripeService } from 'ngx-stripe'; +import { switchMap } from 'rxjs'; +import { environment } from '../../../environments/environment'; import { SharedModule } from '../../shared/shared/shared.module'; @Component({ @@ -10,8 +15,40 @@ import { SharedModule } from '../../shared/shared/shared.module'; styleUrl: './pricing.component.scss', }) export class PricingComponent { - constructor(public keycloakService: KeycloakService) {} - register() { - this.keycloakService.register({ redirectUri: `${window.location.origin}/account` }); + private apiBaseUrl = environment.apiBaseUrl; + private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined; + constructor(public keycloakService: KeycloakService, private http: HttpClient, private stripeService: StripeService, private activatedRoute: ActivatedRoute) {} + + ngOnInit() { + if (this.id) { + this.checkout(atob(this.id)); + } + } + + register(priceId?: string) { + if (priceId) { + this.keycloakService.register({ redirectUri: `${window.location.origin}/pricing/${btoa(priceId)}` }); + } else { + this.keycloakService.register({ redirectUri: `${window.location.origin}/account` }); + } + } + + checkout(priceId) { + // Check the server.js tab to see an example implementation + this.http + .post(`${this.apiBaseUrl}/bizmatch/payment/create-checkout-session`, { priceId }) + .pipe( + switchMap((session: any) => { + return this.stripeService.redirectToCheckout({ sessionId: session.id }); + }), + ) + .subscribe(result => { + // If `redirectToCheckout` fails due to a browser or network + // error, you should display the localized error message to your + // customer using `error.message`. + if (result.error) { + alert(result.error.message); + } + }); } } diff --git a/bizmatch/src/app/pages/subscription/account/account.component.ts b/bizmatch/src/app/pages/subscription/account/account.component.ts index 09a21a3..cf65e9c 100644 --- a/bizmatch/src/app/pages/subscription/account/account.component.ts +++ b/bizmatch/src/app/pages/subscription/account/account.component.ts @@ -133,7 +133,7 @@ export class AccountComponent { const confirmed = await this.confirmationService.showConfirmation({ message: 'Are you sure you want to switch to Buyer ? All your listings as well as all your professionals informations will be deleted' }); if (confirmed) { const id = this.user.id; - this.user = createDefaultUser(this.user.email, this.user.firstname, this.user.lastname); + this.user = createDefaultUser(this.user.email, this.user.firstname, this.user.lastname, 'free'); this.user.customerType = 'buyer'; this.user.id = id; this.imageService.deleteLogoImagesByMail(this.user.email); diff --git a/bizmatch/src/app/pages/success/success.component.html b/bizmatch/src/app/pages/success/success.component.html new file mode 100644 index 0000000..76e99c2 --- /dev/null +++ b/bizmatch/src/app/pages/success/success.component.html @@ -0,0 +1,31 @@ +
    +
    +
    + @if(user && (user.subscriptionPlan==='professional' || user.subscriptionPlan==='broker')){ + + + +

    Subscription Successful!

    +

    Thank you for subscribing to our service.

    +

    + You have successfully subscribed to the @if(user.subscriptionPlan==='professional'){ + Professional (CPA, Attorney, Title Company) Plan + } @if(user.subscriptionPlan==='broker'){ + Business Broker Plan. } +

    +

    We are excited to have you on board!

    + + + +
    +

    If you have any questions, feel free to contact our support team.

    +
    + } @else { +

    We are processing your subscription - Please be patient

    +
    + } +
    +
    +
    diff --git a/bizmatch/src/app/pages/success/success.component.scss b/bizmatch/src/app/pages/success/success.component.scss new file mode 100644 index 0000000..89a3b51 --- /dev/null +++ b/bizmatch/src/app/pages/success/success.component.scss @@ -0,0 +1,33 @@ +/* HTML:
    */ +.loader { + width: 15px; + aspect-ratio: 1; + position: relative; +} +.loader::before, +.loader::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: #000; +} +.loader::before { + box-shadow: -25px 0; + animation: l8-1 1s infinite linear; +} +.loader::after { + transform: rotate(0deg) translateX(25px); + animation: l8-2 1s infinite linear; +} + +@keyframes l8-1 { + 100% { + transform: translateX(25px); + } +} +@keyframes l8-2 { + 100% { + transform: rotate(-180deg) translateX(25px); + } +} diff --git a/bizmatch/src/app/pages/success/success.component.ts b/bizmatch/src/app/pages/success/success.component.ts new file mode 100644 index 0000000..e3be711 --- /dev/null +++ b/bizmatch/src/app/pages/success/success.component.ts @@ -0,0 +1,62 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { Router, RouterModule } from '@angular/router'; +import { KeycloakService } from 'keycloak-angular'; +import { User } from '../../../../../bizmatch-server/src/models/db.model'; +import { LogService } from '../../services/log.service'; +import { UserService } from '../../services/user.service'; +import { map2User } from '../../utils/utils'; + +@Component({ + selector: 'app-success', + standalone: true, + imports: [CommonModule, RouterModule], + templateUrl: './success.component.html', + styleUrl: './success.component.scss', +}) +export class SuccessComponent { + user: User; + constructor(private keycloakService: KeycloakService, private userService: UserService, private logService: LogService, private router: Router) {} + async ngOnInit() { + let email = null; + try { + const token = await this.keycloakService.getToken(); + const keycloakUser = map2User(token); + email = keycloakUser.email; + this.user = await this.userService.getByMail(email); + this.checkSubscriptionPlan(email); + } catch (e) { + this.checkSubscriptionPlan(email, e.message); + } + } + async checkSubscriptionPlan(email: string, error?: string) { + if (!email) { + this.logService.log({ severity: 'error', text: `Unauthorized Access to Success Page ${error}` }); + this.router.navigate(['home']); + return; + } + + let attempts = 0; + const maxAttempts = 5; + const interval = 5000; // 5 Sekunden + + const intervalId = setInterval(async () => { + if (attempts >= maxAttempts) { + clearInterval(intervalId); + console.error('Max attempts reached'); + return; + } + + attempts++; + + this.user = await this.userService.getByMail(email); + + if (this.user && this.user.subscriptionPlan) { + clearInterval(intervalId); + console.log('Subscription plan is set:', this.user.subscriptionPlan); + } else { + console.log(`Attempt ${attempts}: Subscription plan is not set yet.`); + } + }, interval); + } +} diff --git a/bizmatch/src/styles.scss b/bizmatch/src/styles.scss index 8002dee..c1621d6 100644 --- a/bizmatch/src/styles.scss +++ b/bizmatch/src/styles.scss @@ -90,3 +90,7 @@ p-menubarsub ul { .p-dropdown-panel .p-dropdown-header .p-dropdown-filter { margin-right: 0 !important; } +input::placeholder, +textarea::placeholder { + color: #cfd7e0 !important; +}