Stripe Integration

This commit is contained in:
2024-08-20 23:27:07 +02:00
parent 056db7b199
commit 48bff89526
28 changed files with 728 additions and 21 deletions

View File

@@ -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"
}
}
}

View File

@@ -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' },

View File

@@ -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],

View File

@@ -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']);

View File

@@ -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 }),
});

View File

@@ -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<void> {
await this.mailerService.sendMail({
to: 'support@bizmatch.net',
from: `"Bizmatch Support Team" <info@bizmatch.net>`,
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,
},
});
}
}

View File

@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Subscription Confirmation</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
color: #333333;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #dddddd;
padding-bottom: 20px;
}
.header h1 {
font-size: 24px;
color: #333333;
}
.content {
margin-top: 20px;
}
.content p {
font-size: 16px;
line-height: 1.6;
}
.content .plan-info {
font-weight: bold;
color: #0056b3;
}
.footer {
margin-top: 30px;
text-align: center;
font-size: 14px;
color: #888888;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<h1>Subscription Confirmation</h1>
</div>
<div class="content">
<p>Dear {{firstname}} {{lastname}},</p>
<p>Thank you for subscribing to our service! We are thrilled to have you on board.</p>
<p>Your subscription details are as follows:</p>
<p><span class="plan-info">{{#if (eq subscriptionPlan "professional")}}Professional (CPA, Attorney, Title Company) Plan{{else if (eq subscriptionPlan "broker")}}Business Broker Plan{{/if}}</span></p>
<p>If you have any questions or need further assistance, please feel free to contact our support team at any time.</p>
<p>Thank you for choosing Bizmatch!</p>
<p>Best regards,</p>
<p>The Bizmatch Support Team</p>
</div>
<div class="footer">
<p>© 2024 Bizmatch. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -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: '*',

View File

@@ -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') {

View File

@@ -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 {

View File

@@ -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<void> {
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);
}
}
}

View File

@@ -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 {}

View File

@@ -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<typeof schema>,
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<void> {
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);
}
}

View File

@@ -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 {