Skip to content

Commit

Permalink
Add PayPal payment gateway
Browse files Browse the repository at this point in the history
  • Loading branch information
aelassas committed Jan 31, 2025
1 parent 113fc4f commit 549ba4a
Show file tree
Hide file tree
Showing 33 changed files with 699 additions and 201 deletions.
2 changes: 2 additions & 0 deletions api/.env.docker.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ BC_MINIMUM_AGE=21
BC_EXPO_ACCESS_TOKEN=EXPO_ACCESS_TOKEN
BC_STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
BC_STRIPE_SESSION_EXPIRE_AT=82800
BC_PAYPAL_CLIENT_ID=PAYPAL_CLIENT_ID
BC_PAYPAL_CLIENT_SECRET=PAYPAL_CLIENT_SECRET
BC_ADMIN_EMAIL=admin@bookcars.ma
BC_RECAPTCHA_SECRET=RECAPTCHA_SECRET
BC_WEBSITE_NAME=BookCars
Expand Down
2 changes: 2 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ BC_MINIMUM_AGE=21
BC_EXPO_ACCESS_TOKEN=EXPO_ACCESS_TOKEN
BC_STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
BC_STRIPE_SESSION_EXPIRE_AT=82800
BC_PAYPAL_CLIENT_ID=PAYPAL_CLIENT_ID
BC_PAYPAL_CLIENT_SECRET=PAYPAL_CLIENT_SECRET
BC_ADMIN_EMAIL=admin@bookcars.ma
BC_RECAPTCHA_SECRET=RECAPTCHA_SECRET
BC_WEBSITE_NAME=BookCars
Expand Down
2 changes: 2 additions & 0 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import carRoutes from './routes/carRoutes'
import userRoutes from './routes/userRoutes'
import stripeRoutes from './routes/stripeRoutes'
import countryRoutes from './routes/countryRoutes'
import paypalRoutes from './routes/paypalRoutes'
import * as helper from './common/helper'

const app = express()
Expand Down Expand Up @@ -52,6 +53,7 @@ app.use('/', carRoutes)
app.use('/', userRoutes)
app.use('/', stripeRoutes)
app.use('/', countryRoutes)
app.use('/', paypalRoutes)

i18n.locale = env.DEFAULT_LANGUAGE

Expand Down
14 changes: 14 additions & 0 deletions api/src/common/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,17 @@ export const validateAccessToken = async (socialSignInType: bookcarsTypes.Social

return false
}

/**
* Format PayPal price.
*
* Example:
* 1 1.00
* 1.2 1.20
* 1.341 1.34
* 1.345 1.35
*
* @param {number} price
* @returns {string}
*/
export const formatPayPalPrice = (price: number) => (Math.round(price * 100) / 100).toFixed(2)
15 changes: 15 additions & 0 deletions api/src/config/env.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,20 @@ stripeSessionExpireAt = stripeSessionExpireAt <= 82800 ? stripeSessionExpireAt :
*/
export const STRIPE_SESSION_EXPIRE_AT = stripeSessionExpireAt

/**
* PayPal client ID.
*
* @type {string}
*/
export const PAYPAL_CLIENT_ID = __env__('BC_PAYPAL_CLIENT_ID', false, 'PAYPAL_CLIENT_ID')

/**
* PayPal client secret.
*
* @type {string}
*/
export const PAYPAL_CLIENT_SECRET = __env__('BC_PAYPAL_CLIENT_SECRET', false, 'PAYPAL_CLIENT_SECRET')

/**
* Booking expiration in seconds.
* Bookings created from checkout with Stripe are temporary and are automatically deleted if the payment checkout session expires.
Expand Down Expand Up @@ -485,6 +499,7 @@ export interface Booking extends Document {
customerId?: string
expireAt?: Date
isDeposit: boolean
paypalOrderId?: string
}

/**
Expand Down
6 changes: 6 additions & 0 deletions api/src/config/paypalRoutes.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const routes = {
createPayPalOrder: '/api/create-paypal-order',
checkPayPalOrder: '/api/check-paypal-order/:bookingId/:orderId',
}

export default routes
10 changes: 6 additions & 4 deletions api/src/controllers/bookingController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,13 +265,15 @@ export const checkout = async (req: Request, res: Response) => {
}

if (!body.payLater) {
const { paymentIntentId, sessionId } = body
const { payPal, paymentIntentId, sessionId } = body

if (!paymentIntentId && !sessionId) {
if (!payPal && !paymentIntentId && !sessionId) {
throw new Error('paymentIntentId and sessionId not found')
}

body.booking.customerId = body.customerId
if (!payPal) {
body.booking.customerId = body.customerId
}

if (paymentIntentId) {
const paymentIntent = await stripeAPI.paymentIntents.retrieve(paymentIntentId)
Expand All @@ -291,7 +293,7 @@ export const checkout = async (req: Request, res: Response) => {
let expireAt = new Date()
expireAt.setSeconds(expireAt.getSeconds() + env.BOOKING_EXPIRE_AT)

body.booking.sessionId = body.sessionId
body.booking.sessionId = !payPal ? body.sessionId : undefined
body.booking.status = bookcarsTypes.BookingStatus.Void
body.booking.expireAt = expireAt

Expand Down
122 changes: 122 additions & 0 deletions api/src/controllers/paypalController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { Request, Response } from 'express'
import * as paypal from '../paypal'
import i18n from '../lang/i18n'
import * as logger from '../common/logger'
import * as bookcarsTypes from ':bookcars-types'
import * as env from '../config/env.config'
import Booking from '../models/Booking'
import User from '../models/User'
import Car from '../models/Car'
import * as bookingController from './bookingController'

export const createPayPalOrder = async (req: Request, res: Response) => {
try {
const { bookingId, amount, currency, name }: bookcarsTypes.CreatePayPalOrderPayload = req.body

const orderId = await paypal.createOrder(bookingId, amount, currency, name)

return res.json(orderId)
} catch (err) {
logger.error(`[paypal.createPayPalOrder] ${i18n.t('ERROR')}`, err)
return res.status(400).send(i18n.t('ERROR') + err)
}
}

/**
* Check Paypal order and update booking if the payment succeeded.
*
* @async
* @param {Request} req
* @param {Response} res
* @returns {unknown}
*/
export const checkPayPalOrder = async (req: Request, res: Response) => {
try {
const { bookingId, orderId } = req.params

//
// 1. Retrieve Checkout Sesssion and Booking
//
const booking = await Booking.findOne({ _id: bookingId, expireAt: { $ne: null } })
if (!booking) {
const msg = `Booking with id ${bookingId} not found`
logger.info(`[paypal.checkPaypalOrder] ${msg}`)
return res.status(204).send(msg)
}

let order
try {
order = await paypal.getOrder(orderId)
} catch (err) {
logger.error(`[paypal.checkPaypalOrder] retrieve paypal order error: ${orderId}`, err)
}

if (!order) {
const msg = `Order ${order} not found`
logger.info(`[paypal.checkPaypalOrder] ${msg}`)
return res.status(204).send(msg)
}

//
// 2. Update Booking if the payment succeeded
// (Set BookingStatus to Paid and remove expireAt TTL index)
//
if (order.status === 'APPROVED') {
const supplier = await User.findById(booking.supplier)
if (!supplier) {
throw new Error(`Supplier ${booking.supplier} not found`)
}

booking.paypalOrderId = orderId
booking.expireAt = undefined
booking.status = booking.isDeposit ? bookcarsTypes.BookingStatus.Deposit : bookcarsTypes.BookingStatus.Paid
await booking.save()

// Mark car as unavailable
// await Car.updateOne({ _id: booking.car }, { available: false })
const car = await Car.findById(booking.car)
if (!car) {
throw new Error(`Car ${booking.car} not found`)
}
car.trips += 1
await car.save()

// Send confirmation email to customer
const user = await User.findById(booking.driver)
if (!user) {
throw new Error(`Driver ${booking.driver} not found`)
}

user.expireAt = undefined
await user.save()

if (!await bookingController.confirm(user, supplier, booking, false)) {
return res.sendStatus(400)
}

// Notify supplier
i18n.locale = supplier.language
let message = i18n.t('BOOKING_PAID_NOTIFICATION')
await bookingController.notify(user, booking.id, supplier, message)

// Notify admin
const admin = !!env.ADMIN_EMAIL && await User.findOne({ email: env.ADMIN_EMAIL, type: bookcarsTypes.UserType.Admin })
if (admin) {
i18n.locale = admin.language
message = i18n.t('BOOKING_PAID_NOTIFICATION')
await bookingController.notify(user, booking.id, admin, message)
}

return res.sendStatus(200)
}

//
// 3. Delete Booking if the payment didn't succeed
//
await booking.deleteOne()
return res.status(400).send(order.status)
} catch (err) {
logger.error(`[paypal.checkPaypalOrder] ${i18n.t('ERROR')}`, err)
return res.status(400).send(i18n.t('ERROR') + err)
}
}
3 changes: 3 additions & 0 deletions api/src/models/Booking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ const bookingSchema = new Schema<env.Booking>(
type: Boolean,
default: false,
},
paypalOrderId: {
type: String,
},
expireAt: {
//
// Bookings created from checkout with Stripe are temporary and
Expand Down
100 changes: 100 additions & 0 deletions api/src/paypal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import axios from 'axios'
import * as env from './config/env.config'
import * as helper from './common/helper'

export const getToken = async () => {
const res = await axios.post(
'https://api-m.sandbox.paypal.com/v1/oauth2/token',
{ grant_type: 'client_credentials' },
{
auth: {
username: env.PAYPAL_CLIENT_ID,
password: env.PAYPAL_CLIENT_SECRET,
},
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
)

return res.data.access_token
}

export const createOrder = async (bookingId: string, amount: number, currency: string, name: string) => {
const price = helper.formatPayPalPrice(amount)
const token = await getToken()
const res = await axios.post(
'https://api-m.sandbox.paypal.com/v2/checkout/orders',
{
intent: 'CAPTURE',
payment_source: {
paypal: {
experience_context: {
payment_method_preference: 'IMMEDIATE_PAYMENT_REQUIRED',
landing_page: 'LOGIN',
shipping_preference: 'GET_FROM_FILE',
user_action: 'PAY_NOW',
// return_url: `${helper.trimEnd(env.FRONTEND_HOST, '/')}/checkout-session/${bookingId}`,
// cancel_url: env.FRONTEND_HOST,
},
},
},
purchase_units: [
{
invoice_id: bookingId,
amount: {
currency_code: currency,
value: price,
breakdown: {
item_total: {
currency_code: currency,
value: price,
},
},
},
items: [
{
name,
description: name,
unit_amount: {
currency_code: currency,
value: price,
},
quantity: '1',
// category: 'cars',
// sku: 'sku01',
// image_url: 'https://example.com/static/images/items/1/tshirt_green.jpg',
// url: 'https://example.com/url-to-the-item-being-purchased-1',
// upc: {
// type: 'UPC-A',
// code: '123456789012',
// },
},
],
},
],
},
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
},
)

return res.data.id
}

export const getOrder = async (orderId: string) => {
const token = await getToken()
const res = await axios.get(
`https://api-m.sandbox.paypal.com/v2/checkout/orders/${orderId}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
)

return res.data
}
10 changes: 10 additions & 0 deletions api/src/routes/paypalRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import express from 'express'
import routeNames from '../config/paypalRoutes.config'
import * as paypalController from '../controllers/paypalController'

const routes = express.Router()

routes.route(routeNames.createPayPalOrder).post(paypalController.createPayPalOrder)
routes.route(routeNames.checkPayPalOrder).post(paypalController.checkPayPalOrder)

export default routes
2 changes: 2 additions & 0 deletions frontend/.env.docker.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ VITE_BC_CAR_IMAGE_WIDTH=300
VITE_BC_CAR_IMAGE_HEIGHT=200
VITE_BC_MINIMUM_AGE=21
VITE_BC_PAGINATION_MODE=classic
VITE_BC_PAYMENT_GATEWAY=Stripe
VITE_BC_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
VITE_BC_PAYPAL_CLIENT_ID=PAYPAL_CLIENT_ID
VITE_BC_SET_LANGUAGE_FROM_IP=false
VITE_BC_GOOGLE_ANALYTICS_ENABLED=false
VITE_BC_GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX
Expand Down
2 changes: 2 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ VITE_BC_CAR_IMAGE_WIDTH=300
VITE_BC_CAR_IMAGE_HEIGHT=200
VITE_BC_MINIMUM_AGE=21
VITE_BC_PAGINATION_MODE=classic
VITE_BC_PAYMENT_GATEWAY=Stripe
VITE_BC_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
VITE_BC_PAYPAL_CLIENT_ID=PAYPAL_CLIENT_ID
VITE_BC_SET_LANGUAGE_FROM_IP=false
VITE_BC_GOOGLE_ANALYTICS_ENABLED=false
VITE_BC_GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX
Expand Down
Loading

0 comments on commit 549ba4a

Please sign in to comment.