Stripe Integration
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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: '*',
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
36
bizmatch-server/src/payment/payment.controller.ts
Normal file
36
bizmatch-server/src/payment/payment.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
bizmatch-server/src/payment/payment.module.ts
Normal file
17
bizmatch-server/src/payment/payment.module.ts
Normal 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 {}
|
||||
81
bizmatch-server/src/payment/payment.service.ts
Normal file
81
bizmatch-server/src/payment/payment.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user