Skip to content

Commit

Permalink
Add Stripe payment gateway
Browse files Browse the repository at this point in the history
  • Loading branch information
aelassas committed Apr 26, 2024
1 parent c116c44 commit 6cfc6a1
Show file tree
Hide file tree
Showing 30 changed files with 1,044 additions and 762 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ jobs:
echo MI_FRONTEND_HOST=$MI_FRONTEND_HOST >> .env
echo MI_MINIMUM_AGE=$MI_MINIMUM_AGE >> .env
echo MI_EXPO_ACCESS_TOKEN=$MI_EXPO_ACCESS_TOKEN >> .env
echo MI_STRIPE_SECRET_KEY=$MI_STRIPE_SECRET_KEY >> .env
npm install
npm test
env:
Expand Down Expand Up @@ -82,6 +83,7 @@ jobs:
MI_FRONTEND_HOST: ${{ vars.MI_FRONTEND_HOST }}
MI_MINIMUM_AGE: ${{ vars.MI_MINIMUM_AGE }}
MI_EXPO_ACCESS_TOKEN: ${{ secrets.MI_EXPO_ACCESS_TOKEN }}
MI_STRIPE_SECRET_KEY: ${{ secrets.MI_STRIPE_SECRET_KEY }}
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4
with:
Expand Down
1 change: 1 addition & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ MI_BACKEND_HOST=http://localhost:3003/
MI_FRONTEND_HOST=http://localhost:3004/
MI_MINIMUM_AGE=21
MI_EXPO_ACCESS_TOKEN=EXPO_ACCESS_TOKEN
MI_STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
13 changes: 13 additions & 0 deletions api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"nocache": "^4.0.0",
"nodemailer": "^6.9.13",
"rimraf": "^5.0.5",
"stripe": "^15.4.0",
"supertest": "^6.3.4",
"typescript": "^5.4.5",
"uuid": "^9.0.1",
Expand Down
2 changes: 2 additions & 0 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import locationRoutes from './routes/locationRoutes'
import notificationRoutes from './routes/notificationRoutes'
import propertyRoutes from './routes/propertyRoutes'
import userRoutes from './routes/userRoutes'
import stripeRoutes from './routes/stripeRoutes'
import * as helper from './common/helper'

const app = express()
Expand Down Expand Up @@ -48,6 +49,7 @@ app.use('/', locationRoutes)
app.use('/', notificationRoutes)
app.use('/', propertyRoutes)
app.use('/', userRoutes)
app.use('/', stripeRoutes)

i18n.locale = env.DEFAULT_LANGUAGE

Expand Down
4 changes: 2 additions & 2 deletions api/src/common/authHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as env from '../config/env.config'
* @param {Request} req
* @returns {boolean}
*/
export const isBackend = (req: Request): boolean => !!req.headers.origin && helper.trim(req.headers.origin, '/') === helper.trim(env.BACKEND_HOST, '/')
export const isBackend = (req: Request): boolean => !!req.headers.origin && helper.trimEnd(req.headers.origin, '/') === helper.trimEnd(env.BACKEND_HOST, '/')

/**
* Check whether the request is from the frontend or not.
Expand All @@ -18,7 +18,7 @@ export const isBackend = (req: Request): boolean => !!req.headers.origin && help
* @param {Request} req
* @returns {boolean}
*/
export const isFrontend = (req: Request): boolean => !!req.headers.origin && helper.trim(req.headers.origin, '/') === helper.trim(env.FRONTEND_HOST, '/')
export const isFrontend = (req: Request): boolean => !!req.headers.origin && helper.trimEnd(req.headers.origin, '/') === helper.trimEnd(env.FRONTEND_HOST, '/')

/**
* Get authentification cookie name.
Expand Down
76 changes: 53 additions & 23 deletions api/src/common/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import { v1 as uuid } from 'uuid'
* @returns {boolean}
*/
export const StringToBoolean = (input: string): boolean => {
try {
return Boolean(JSON.parse(input.toLowerCase()))
} catch {
return false
}
try {
return Boolean(JSON.parse(input.toLowerCase()))
} catch {
return false
}
}

/**
Expand All @@ -28,12 +28,12 @@ export const StringToBoolean = (input: string): boolean => {
* @returns {Promise<boolean>}
*/
export const exists = async (filePath: string): Promise<boolean> => {
try {
await fs.access(filePath)
return true
} catch {
return false
}
try {
await fs.access(filePath)
return true
} catch {
return false
}
}

/**
Expand All @@ -46,7 +46,23 @@ export const exists = async (filePath: string): Promise<boolean> => {
* @returns {Promise<void>}
*/
export const mkdir = async (folder: string) => {
await fs.mkdir(folder, { recursive: true })
await fs.mkdir(folder, { recursive: true })
}

/**
* Removes a start line terminator character from a string.
*
* @export
* @param {string} str
* @param {string} char
* @returns {string}
*/
export const trimStart = (str: string, char: string): string => {
let res = str
while (res.charAt(0) === char) {
res = res.substring(1, res.length)
}
return res
}

/**
Expand All @@ -57,12 +73,26 @@ export const mkdir = async (folder: string) => {
* @param {string} char
* @returns {string}
*/
export const trimEnd = (str: string, char: string): string => {
let res = str
while (res.charAt(res.length - 1) === char) {
res = res.substring(0, res.length - 1)
}
return res
}

/**
* Removes a stating, leading and trailing line terminator character from a string.
*
* @export
* @param {string} str
* @param {string} char
* @returns {string}
*/
export const trim = (str: string, char: string): string => {
let res = str
while (res.charAt(res.length - 1) === char) {
res = res.substring(0, res.length - 1)
}
return res
let res = trimStart(str, char)
res = trimEnd(res, char)
return res
}

/**
Expand All @@ -74,14 +104,14 @@ export const trim = (str: string, char: string): string => {
* @returns {string}
*/
export const joinURL = (part1: string, part2: string): string => {
const p1 = trim(part1, '/')
let p2 = part2
const p1 = trimEnd(part1, '/')
let p2 = part2

if (part2.charAt(0) === '/') {
p2 = part2.substring(1)
}
if (part2.charAt(0) === '/') {
p2 = part2.substring(1)
}

return `${p1}/${p2}`
return `${p1}/${p2}`
}

/**
Expand Down
2 changes: 1 addition & 1 deletion api/src/common/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const info = (message: string, obj?: any) => {

export const error = (message: string, err?: unknown) => {
if (err instanceof Error) {
logger.error(`${message} ${err.message} ${err.stack}`)
logger.error(`${message} ${err.message}`) // ${err.stack}
} else {
logger.error(message)
}
Expand Down
8 changes: 8 additions & 0 deletions api/src/config/env.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,13 @@ export const MINIMUM_AGE = Number.parseInt(__env__('MI_MINIMUM_AGE', false, '21'
*/
export const EXPO_ACCESS_TOKEN = __env__('MI_EXPO_ACCESS_TOKEN', false)

/**
* Stripe secret key.
*
* @type {string}
*/
export const STRIPE_SECRET_KEY = __env__('MI_STRIPE_SECRET_KEY', false, 'STRIPE_SECRET_KEY')

/**
* User Document.
*
Expand All @@ -280,6 +287,7 @@ export interface User extends Document {
type?: movininTypes.UserType
blacklisted?: boolean
payLater?: boolean
customerId?: string
}

/**
Expand Down
5 changes: 5 additions & 0 deletions api/src/config/stripeRoutes.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const routes = {
createPaymentIntent: '/api/create-payment-intent',
}

export default routes
31 changes: 30 additions & 1 deletion api/src/controllers/bookingController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as env from '../config/env.config'
import * as mailHelper from '../common/mailHelper'
import * as helper from '../common/helper'
import * as logger from '../common/logger'
import stripeAPI from '../stripe'

/**
* Create a Booking.
Expand Down Expand Up @@ -100,6 +101,28 @@ export const checkout = async (req: Request, res: Response) => {
const { body }: { body: movininTypes.CheckoutPayload } = req
const { renter } = body

if (!body.booking) {
throw new Error('Booking missing')
}

if (!body.payLater) {
const { paymentIntentId } = body
if (!paymentIntentId) {
const message = 'Payment intent missing'
logger.error(message, body)
return res.status(400).send(message)
}

const paymentIntent = await stripeAPI.paymentIntents.retrieve(paymentIntentId)
if (paymentIntent.status !== 'succeeded') {
const message = `Payment failed: ${paymentIntent.status}`
logger.error(message, body)
return res.status(400).send(message)
}

body.booking.status = movininTypes.BookingStatus.Paid
}

if (renter) {
renter.verified = false
renter.blacklisted = false
Expand Down Expand Up @@ -133,6 +156,12 @@ export const checkout = async (req: Request, res: Response) => {
return res.sendStatus(204)
}

const { customerId } = body
if (customerId) {
user.customerId = customerId
await user?.save()
}

const { language } = user
i18n.locale = language

Expand Down Expand Up @@ -194,7 +223,7 @@ export const checkout = async (req: Request, res: Response) => {

return res.sendStatus(200)
} catch (err) {
logger.error(`[booking.book] ${i18n.t('ERROR')}`, err)
logger.error(`[booking.checkout] ${i18n.t('ERROR')}`, err)
return res.status(400).send(i18n.t('ERROR') + err)
}
}
Expand Down
68 changes: 68 additions & 0 deletions api/src/controllers/stripeController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Request, Response } from 'express'
import Stripe from 'stripe'
import stripeAPI from '../stripe'
import i18n from '../lang/i18n'
import * as logger from '../common/logger'
import * as movininTypes from ':movinin-types'

/**
* Create Payment Intent.
*
* @async
* @param {Request} req
* @param {Response} res
* @returns {unknown}
*/
export const createPaymentIntent = async (req: Request, res: Response) => {
const {
amount,
currency,
receiptEmail,
description,
customerName,
}: movininTypes.CreatePaymentIntentPayload = req.body

try {
//
// 1. Create the customer if he does not already exist
//
const customers = await stripeAPI.customers.list({ email: receiptEmail })

let customer: Stripe.Customer
if (customers.data.length === 0) {
customer = await stripeAPI.customers.create({
email: receiptEmail,
name: customerName,
})
} else {
[customer] = customers.data
}

//
// 2. Create payment intent
//
const paymentIntent = await stripeAPI.paymentIntents.create({
// All API requests expect amounts to be provided in a currency’s smallest unit.
// For example, to charge 10 USD, provide an amount value of 1000 (that is, 1000 cents).
amount: Math.floor(amount * 100),
currency,
payment_method_types: ['card'],
receipt_email: receiptEmail,
description,
customer: customer.id,
})

//
// 3. Send result
//
const result: movininTypes.PaymentIntentResult = {
paymentIntentId: paymentIntent.id,
customerId: customer.id,
clientSecret: paymentIntent.client_secret,
}
return res.status(200).json(result)
} catch (err) {
logger.error(`[stripe.createPaymentIntent] ${i18n.t('ERROR')}`, err)
return res.status(400).send(i18n.t('ERROR') + err)
}
}
Loading

0 comments on commit 6cfc6a1

Please sign in to comment.