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 80aeeb0 commit af1ed7b
Show file tree
Hide file tree
Showing 24 changed files with 666 additions and 125 deletions.
2 changes: 2 additions & 0 deletions api/.env.docker.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,7 @@ WC_DEFAULT_CURRENCY=$
WC_DEFAULT_STRIPE_CURRENCY=USD
WC_STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
WC_STRIPE_SESSION_EXPIRE_AT=82800
WC_PAYPAL_CLIENT_ID=PAYPAL_CLIENT_ID
WC_PAYPAL_CLIENT_SECRET=PAYPAL_CLIENT_SECRET
WC_ADMIN_EMAIL=admin@wexcommerce.com
WC_RECAPTCHA_SECRET=RECAPTCHA_SECRET
2 changes: 2 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,7 @@ WC_DEFAULT_CURRENCY=$
WC_DEFAULT_STRIPE_CURRENCY=USD
WC_STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
WC_STRIPE_SESSION_EXPIRE_AT=82800
WC_PAYPAL_CLIENT_ID=PAYPAL_CLIENT_ID
WC_PAYPAL_CLIENT_SECRET=PAYPAL_CLIENT_SECRET
WC_ADMIN_EMAIL=admin@wexcommerce.com
WC_RECAPTCHA_SECRET=RECAPTCHA_SECRET
2 changes: 2 additions & 0 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import paymentTypeRoutes from './routes/paymentTypeRoutes'
import settingRoutes from './routes/settingRoutes'
import stripeRoutes from './routes/stripeRoutes'
import wishlistRoutes from './routes/wishlistRoutes'
import paypalRoutes from './routes/paypalRoutes'
import * as helper from './common/helper'

const app = express()
Expand Down Expand Up @@ -58,6 +59,7 @@ app.use('/', paymentTypeRoutes)
app.use('/', settingRoutes)
app.use('/', stripeRoutes)
app.use('/', wishlistRoutes)
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 @@ -296,3 +296,17 @@ export const formatPrice = (price: number, currency: string, language: string) =

return `${formatedPrice} ${currency}`
}

/**
* 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 @@ -299,6 +299,20 @@ stripeSessionExpireAt = stripeSessionExpireAt <= 82800 ? stripeSessionExpireAt :
*/
export const STRIPE_SESSION_EXPIRE_AT = stripeSessionExpireAt

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

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

/**
* Order expiration in seconds.
* Orders created from checkout with Stripe are temporary and are automatically deleted if the payment checkout session expires.
Expand Down Expand Up @@ -554,6 +568,7 @@ export interface Order extends Document {
paymentIntentId?: string
customerId?: string
expireAt?: Date
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/:orderId/:paypalOrderId',
}

export default routes
10 changes: 6 additions & 4 deletions api/src/controllers/orderController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,15 @@ export const checkout = async (req: Request, res: Response) => {
const deliveryType = (await DeliveryType.findById(order.deliveryType))!.name

if (paymentType === wexcommerceTypes.PaymentType.CreditCard) {
const { paymentIntentId, sessionId } = body
const { payPal, paymentIntentId, sessionId } = body

if (!paymentIntentId && !sessionId) {
if (!payPal && !paymentIntentId && !sessionId) {
throw new Error('Payment intent and session missing')
}

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

if (paymentIntentId) {
const paymentIntent = await stripeAPI.paymentIntents.retrieve(paymentIntentId)
Expand All @@ -222,7 +224,7 @@ export const checkout = async (req: Request, res: Response) => {
await orderItem!.save()
}

_order.sessionId = body.sessionId
_order.sessionId = !payPal ? body.sessionId : undefined
_order.status = wexcommerceTypes.OrderStatus.Pending
_order.expireAt = expireAt

Expand Down
140 changes: 140 additions & 0 deletions api/src/controllers/paypalController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { Request, Response } from 'express'
import * as paypal from '../paypal'
import i18n from '../lang/i18n'
import * as logger from '../common/logger'
import * as wexcommerceTypes from ':wexcommerce-types'
import * as env from '../config/env.config'
import Order from '../models/Order'
import OrderItem from '../models/OrderItem'
import Product from '../models/Product'
import User from '../models/User'
import Setting from '../models/Setting'
import PaymentType from '../models/PaymentType'
import DeliveryType from '../models/DeliveryType'
import * as orderController from './orderController'

export const createPayPalOrder = async (req: Request, res: Response) => {
try {
const { orderId, amount, currency, name }: wexcommerceTypes.CreatePayPalOrderPayload = req.body
console.log({ orderId, amount, currency, name })

const paypalOrderId = await paypal.createOrder(orderId, amount, currency, name)
console.log('paypalOrderId', paypalOrderId)

return res.json(paypalOrderId)
} 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 { orderId, paypalOrderId } = req.params

//
// 1. Retrieve Checkout Sesssion and Booking
//
const order = await Order
.findOne({ _id: orderId, expireAt: { $ne: null } })
.populate<{ orderItems: env.OrderItem[] }>({
path: 'orderItems',
populate: {
path: 'product',
model: 'Product',
},
})

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

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

if (!paypalOrder) {
const msg = `Order ${paypalOrder} 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 (paypalOrder.status === 'APPROVED') {
for (const oi of order.orderItems) {
const orderItem = await OrderItem.findById(oi.id)
orderItem!.expireAt = undefined
await orderItem!.save()
}
order.expireAt = undefined
order.status = wexcommerceTypes.OrderStatus.Paid
await order.save()

// Update product quantity
for (const orderItem of order.orderItems) {
const product = await Product.findById(orderItem.product._id)
if (!product) {
throw new Error(`Product ${orderItem.product._id} not found`)
}
product.quantity -= orderItem.quantity
if (product.quantity <= 0) {
product.soldOut = true
product.quantity = 0
}
await product.save()
}

// Send confirmation email
const user = await User.findById(order.user)
if (!user) {
logger.info(`User ${order.user} not found`)
return res.sendStatus(204)
}

user.expireAt = undefined
await user.save()

const settings = await Setting.findOne({})
if (!settings) {
throw new Error('Settings not found')
}
const paymentType = (await PaymentType.findById(order.paymentType))!.name
const deliveryType = (await DeliveryType.findById(order.deliveryType))!.name
await orderController.confirm(user, order, order.orderItems, settings, paymentType, deliveryType)

// Notify admin
// const admin = !!env.ADMIN_EMAIL && await User.findOne({ email: env.ADMIN_EMAIL, type: wexcommerceTypes.UserType.Admin })
// if (admin) {
// await orderController.notify(env.ADMIN_EMAIL, order, user, settings)
// }
await orderController.notify(env.ADMIN_EMAIL, order, user, settings)

return res.sendStatus(200)
}

//
// 3. Delete Booking if the payment didn't succeed
//
await order.deleteOne()
return res.status(400).send(paypalOrder.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/Order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ const orderSchema = new Schema<env.Order>({
customerId: {
type: String,
},
paypalOrderId: {
type: String,
},
expireAt: {
//
// Orders 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 (orderId: 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: orderId,
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 @@ -8,6 +8,8 @@ NEXT_PUBLIC_WC_CDN_PRODUCTS=http://localhost/cdn/wexcommerce/products
NEXT_PUBLIC_WC_FB_APP_ID=XXXXXXXXXX
NEXT_PUBLIC_WC_APPLE_ID=XXXXXXXXXX
NEXT_PUBLIC_WC_GG_APP_ID=XXXXXXXXXX
NEXT_PUBLIC_WC_PAYMENT_GATEWAY=Stripe
NEXT_PUBLIC_WC_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
NEXT_PUBLIC_WC_PAYPAL_CLIENT_ID=PAYPAL_CLIENT_ID
NEXT_PUBLIC_WC_GOOGLE_ANALYTICS_ENABLED=false
NEXT_PUBLIC_WC_GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX
2 changes: 2 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ NEXT_PUBLIC_WC_CDN_PRODUCTS=http://localhost/cdn/wexcommerce/products
NEXT_PUBLIC_WC_FB_APP_ID=XXXXXXXXXX
NEXT_PUBLIC_WC_APPLE_ID=XXXXXXXXXX
NEXT_PUBLIC_WC_GG_APP_ID=XXXXXXXXXX
NEXT_PUBLIC_WC_PAYMENT_GATEWAY=Stripe
NEXT_PUBLIC_WC_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY
NEXT_PUBLIC_WC_PAYPAL_CLIENT_ID=PAYPAL_CLIENT_ID
NEXT_PUBLIC_WC_GOOGLE_ANALYTICS_ENABLED=false
NEXT_PUBLIC_WC_GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX
NEXT_PUBLIC_WC_RECAPTCHA_ENABLED=false
Expand Down
Loading

0 comments on commit af1ed7b

Please sign in to comment.