From 6cfc6a1f8fa5e6240cb6154d799a643d355c63fb Mon Sep 17 00:00:00 2001 From: aelassas Date: Fri, 26 Apr 2024 11:03:54 +0100 Subject: [PATCH] Add Stripe payment gateway --- .github/workflows/test.yml | 2 + api/.env.example | 1 + api/package-lock.json | 13 + api/package.json | 1 + api/src/app.ts | 2 + api/src/common/authHelper.ts | 4 +- api/src/common/helper.ts | 76 +- api/src/common/logger.ts | 2 +- api/src/config/env.config.ts | 8 + api/src/config/stripeRoutes.config.ts | 5 + api/src/controllers/bookingController.ts | 31 +- api/src/controllers/stripeController.ts | 68 ++ api/src/controllers/userController.ts | 6 +- api/src/models/User.ts | 3 + api/src/routes/stripeRoutes.ts | 9 + api/src/stripe.ts | 6 + api/tests/booking.test.ts | 912 +++++++++++---------- api/tests/helper.test.ts | 6 + api/tests/stripe.test.ts | 56 ++ frontend/.env.example | 33 +- frontend/package-lock.json | 23 + frontend/package.json | 2 + frontend/src/App.tsx | 61 +- frontend/src/assets/css/checkout.css | 33 +- frontend/src/assets/img/secure-payment.png | Bin 10157 -> 17049 bytes frontend/src/config/env.config.ts | 1 + frontend/src/lang/checkout.ts | 24 +- frontend/src/pages/Checkout.tsx | 378 ++++----- frontend/src/services/StripeService.ts | 16 + packages/movinin-types/index.ts | 24 +- 30 files changed, 1044 insertions(+), 762 deletions(-) create mode 100644 api/src/config/stripeRoutes.config.ts create mode 100644 api/src/controllers/stripeController.ts create mode 100644 api/src/routes/stripeRoutes.ts create mode 100644 api/src/stripe.ts create mode 100644 api/tests/stripe.test.ts create mode 100644 frontend/src/services/StripeService.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3642106b..e9dd9c3e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: @@ -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: diff --git a/api/.env.example b/api/.env.example index 8ee796d5..2c57ca27 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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 diff --git a/api/package-lock.json b/api/package-lock.json index 8c9e741a..2105cddb 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -48,6 +48,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", @@ -11719,6 +11720,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-15.4.0.tgz", + "integrity": "sha512-o3STlHYUmJh1ogAem434As7hCMEGG43R1fFkX0NuxabnmZoOQ9Ytxuu+e5Tq5NSE3LPUIV64jbjQebHoZvLTKw==", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/superagent": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", diff --git a/api/package.json b/api/package.json index 8a3acf77..f0c98b5e 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/src/app.ts b/api/src/app.ts index 4234aa23..1a2620ed 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -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() @@ -48,6 +49,7 @@ app.use('/', locationRoutes) app.use('/', notificationRoutes) app.use('/', propertyRoutes) app.use('/', userRoutes) +app.use('/', stripeRoutes) i18n.locale = env.DEFAULT_LANGUAGE diff --git a/api/src/common/authHelper.ts b/api/src/common/authHelper.ts index 8b504e2d..4ed6368c 100644 --- a/api/src/common/authHelper.ts +++ b/api/src/common/authHelper.ts @@ -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. @@ -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. diff --git a/api/src/common/helper.ts b/api/src/common/helper.ts index b1418e62..8da67359 100644 --- a/api/src/common/helper.ts +++ b/api/src/common/helper.ts @@ -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 + } } /** @@ -28,12 +28,12 @@ export const StringToBoolean = (input: string): boolean => { * @returns {Promise} */ export const exists = async (filePath: string): Promise => { - try { - await fs.access(filePath) - return true - } catch { - return false - } + try { + await fs.access(filePath) + return true + } catch { + return false + } } /** @@ -46,7 +46,23 @@ export const exists = async (filePath: string): Promise => { * @returns {Promise} */ 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 } /** @@ -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 } /** @@ -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}` } /** diff --git a/api/src/common/logger.ts b/api/src/common/logger.ts index 5e3ae5a3..c39a6b72 100644 --- a/api/src/common/logger.ts +++ b/api/src/common/logger.ts @@ -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) } diff --git a/api/src/config/env.config.ts b/api/src/config/env.config.ts index 52b10f40..7dec803d 100644 --- a/api/src/config/env.config.ts +++ b/api/src/config/env.config.ts @@ -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. * @@ -280,6 +287,7 @@ export interface User extends Document { type?: movininTypes.UserType blacklisted?: boolean payLater?: boolean + customerId?: string } /** diff --git a/api/src/config/stripeRoutes.config.ts b/api/src/config/stripeRoutes.config.ts new file mode 100644 index 00000000..e7f92da4 --- /dev/null +++ b/api/src/config/stripeRoutes.config.ts @@ -0,0 +1,5 @@ +const routes = { + createPaymentIntent: '/api/create-payment-intent', +} + +export default routes diff --git a/api/src/controllers/bookingController.ts b/api/src/controllers/bookingController.ts index 3b0da8e2..bf7f7d8e 100644 --- a/api/src/controllers/bookingController.ts +++ b/api/src/controllers/bookingController.ts @@ -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. @@ -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 @@ -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 @@ -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) } } diff --git a/api/src/controllers/stripeController.ts b/api/src/controllers/stripeController.ts new file mode 100644 index 00000000..378f22c6 --- /dev/null +++ b/api/src/controllers/stripeController.ts @@ -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) + } +} diff --git a/api/src/controllers/userController.ts b/api/src/controllers/userController.ts index ea86b70c..73918abf 100644 --- a/api/src/controllers/userController.ts +++ b/api/src/controllers/userController.ts @@ -43,6 +43,7 @@ const _signup = async (req: Request, res: Response, userType: movininTypes.UserT const { body }: { body: movininTypes.SignUpPayload } = req try { + body.email = helper.trim(body.email, ' ') body.active = true body.verified = false body.blacklisted = false @@ -391,7 +392,8 @@ export const activate = async (req: Request, res: Response) => { */ export const signin = async (req: Request, res: Response) => { const { body }: { body: movininTypes.SignInPayload } = req - const { email, password, stayConnected, mobile } = body + const { email: emailFromBody, password, stayConnected, mobile } = body + const email = helper.trim(emailFromBody, ' ') try { if (!helper.isValidEmail(email)) { @@ -911,6 +913,7 @@ export const getUser = async (req: Request, res: Response) => { blacklisted: 1, birthDate: 1, payLater: 1, + customerId: 1, }).lean() if (!user) { @@ -1214,6 +1217,7 @@ export const getUsers = async (req: Request, res: Response) => { type: 1, blacklisted: 1, birthDate: 1, + customerId: 1, }, }, { diff --git a/api/src/models/User.ts b/api/src/models/User.ts index 02104b10..c03c050b 100644 --- a/api/src/models/User.ts +++ b/api/src/models/User.ts @@ -95,6 +95,9 @@ const userSchema = new Schema( type: Boolean, default: true, }, + customerId: { + type: String, + }, }, { timestamps: true, diff --git a/api/src/routes/stripeRoutes.ts b/api/src/routes/stripeRoutes.ts new file mode 100644 index 00000000..7e7703c3 --- /dev/null +++ b/api/src/routes/stripeRoutes.ts @@ -0,0 +1,9 @@ +import express from 'express' +import routeNames from '../config/stripeRoutes.config' +import * as stripeController from '../controllers/stripeController' + +const routes = express.Router() + +routes.route(routeNames.createPaymentIntent).post(stripeController.createPaymentIntent) + +export default routes diff --git a/api/src/stripe.ts b/api/src/stripe.ts new file mode 100644 index 00000000..222fd0b0 --- /dev/null +++ b/api/src/stripe.ts @@ -0,0 +1,6 @@ +import Stripe from 'stripe' +import * as env from './config/env.config' + +const stripeAPI = new Stripe(env.STRIPE_SECRET_KEY) + +export default stripeAPI diff --git a/api/tests/booking.test.ts b/api/tests/booking.test.ts index 0d290ea1..d2fe0b14 100644 --- a/api/tests/booking.test.ts +++ b/api/tests/booking.test.ts @@ -11,6 +11,7 @@ import User from '../src/models/User' import PushToken from '../src/models/PushToken' import Token from '../src/models/Token' import * as env from '../src/config/env.config' +import stripeAPI from '../src/stripe' let AGENCY_ID: string let RENTER1_ID: string @@ -24,76 +25,76 @@ let BOOKING_ID: string // Connecting and initializing the database before running the test suite // beforeAll(async () => { - if (await databaseHelper.Connect(env.DB_URI, false, false)) { - await testHelper.initialize() - - // create a supplier - const supplierName = testHelper.getAgencyName() - AGENCY_ID = await testHelper.createAgency(`${supplierName}@test.movinin.io`, supplierName) - - // get user id - RENTER1_ID = testHelper.getUserId() - - // create a location - LOCATION_ID = await testHelper.createLocation('Location 1 EN', 'Location 1 FR') - - // create property - const payload: movininTypes.CreatePropertyPayload = { - name: 'Beautiful House in Detroit', - agency: AGENCY_ID, - type: movininTypes.PropertyType.House, - description: '

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium rem aperiam, veritatis et quasi.

', - image: 'house.jpg', - images: [], - bedrooms: 3, - bathrooms: 2, - kitchens: 1, - parkingSpaces: 1, - size: 200, - petsAllowed: false, - furnished: true, - aircon: true, - minimumAge: 21, - location: LOCATION_ID, - address: '', - price: 1000, - hidden: true, - cancellation: 0, - available: false, - rentalTerm: movininTypes.RentalTerm.Daily, - } - - // property 1 - let property = new Property(payload) - await property.save() - PROPERTY1_ID = property.id - - // property 2 - property = new Property({ ...payload, name: 'Beautiful Townhouse in Detroit', price: 1200 }) - await property.save() - PROPERTY2_ID = property.id + if (await databaseHelper.Connect(env.DB_URI, false, false)) { + await testHelper.initialize() + + // create a supplier + const supplierName = testHelper.getAgencyName() + AGENCY_ID = await testHelper.createAgency(`${supplierName}@test.movinin.io`, supplierName) + + // get user id + RENTER1_ID = testHelper.getUserId() + + // create a location + LOCATION_ID = await testHelper.createLocation('Location 1 EN', 'Location 1 FR') + + // create property + const payload: movininTypes.CreatePropertyPayload = { + name: 'Beautiful House in Detroit', + agency: AGENCY_ID, + type: movininTypes.PropertyType.House, + description: '

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium rem aperiam, veritatis et quasi.

', + image: 'house.jpg', + images: [], + bedrooms: 3, + bathrooms: 2, + kitchens: 1, + parkingSpaces: 1, + size: 200, + petsAllowed: false, + furnished: true, + aircon: true, + minimumAge: 21, + location: LOCATION_ID, + address: '', + price: 1000, + hidden: true, + cancellation: 0, + available: false, + rentalTerm: movininTypes.RentalTerm.Daily, } + + // property 1 + let property = new Property(payload) + await property.save() + PROPERTY1_ID = property.id + + // property 2 + property = new Property({ ...payload, name: 'Beautiful Townhouse in Detroit', price: 1200 }) + await property.save() + PROPERTY2_ID = property.id + } }) // // Closing and cleaning the database connection after running the test suite // afterAll(async () => { - await testHelper.close() + await testHelper.close() - // delete the supplier - await testHelper.deleteAgency(AGENCY_ID) + // delete the supplier + await testHelper.deleteAgency(AGENCY_ID) - // delete the location - await testHelper.deleteLocation(LOCATION_ID) + // delete the location + await testHelper.deleteLocation(LOCATION_ID) - // delete the property - await Property.deleteMany({ _id: { $in: [PROPERTY1_ID, PROPERTY2_ID] } }) + // delete the property + await Property.deleteMany({ _id: { $in: [PROPERTY1_ID, PROPERTY2_ID] } }) - // delete renters - await User.deleteOne({ _id: { $in: [RENTER1_ID, RENTER2_ID] } }) + // delete renters + await User.deleteOne({ _id: { $in: [RENTER1_ID, RENTER2_ID] } }) - await databaseHelper.Close() + await databaseHelper.Close() }) // @@ -101,406 +102,447 @@ afterAll(async () => { // describe('POST /api/create-booking', () => { - it('should create a booking', async () => { - const token = await testHelper.signinAsAdmin() - - const payload: movininTypes.Booking = { - agency: AGENCY_ID, - property: PROPERTY1_ID, - renter: RENTER1_ID, - location: LOCATION_ID, - from: new Date(2024, 2, 1), - to: new Date(1990, 2, 4), - status: movininTypes.BookingStatus.Pending, - cancellation: true, - price: 4000, - } - let res = await request(app) - .post('/api/create-booking') - .set(env.X_ACCESS_TOKEN, token) - .send(payload) - expect(res.statusCode).toBe(200) - BOOKING_ID = res.body._id - - res = await request(app) - .post('/api/create-booking') - .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(400) - - await testHelper.signout(token) - }) + it('should create a booking', async () => { + const token = await testHelper.signinAsAdmin() + + const payload: movininTypes.Booking = { + agency: AGENCY_ID, + property: PROPERTY1_ID, + renter: RENTER1_ID, + location: LOCATION_ID, + from: new Date(2024, 2, 1), + to: new Date(1990, 2, 4), + status: movininTypes.BookingStatus.Pending, + cancellation: true, + price: 4000, + } + let res = await request(app) + .post('/api/create-booking') + .set(env.X_ACCESS_TOKEN, token) + .send(payload) + expect(res.statusCode).toBe(200) + BOOKING_ID = res.body._id + + res = await request(app) + .post('/api/create-booking') + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(400) + + await testHelper.signout(token) + }) }) describe('POST /api/checkout', () => { - it('should checkout', async () => { - let bookings = await Booking.find({ renter: RENTER1_ID }) - expect(bookings.length).toBe(1) - - const payload: movininTypes.CheckoutPayload = { - booking: { - agency: AGENCY_ID, - property: PROPERTY1_ID, - renter: RENTER1_ID, - location: LOCATION_ID, - from: new Date(2024, 3, 1), - to: new Date(1990, 3, 4), - status: movininTypes.BookingStatus.Pending, - cancellation: true, - price: 4000, - }, - payLater: true, - } - let res = await request(app) - .post('/api/checkout') - .send(payload) - expect(res.statusCode).toBe(200) - bookings = await Booking.find({ renter: RENTER1_ID }) - expect(bookings.length).toBeGreaterThan(1) - - payload.payLater = false - const renter = await User.findOne({ _id: RENTER1_ID }) - renter!.language = 'fr' - await renter?.save() - res = await request(app) - .post('/api/checkout') - .send(payload) - expect(res.statusCode).toBe(200) - bookings = await Booking.find({ renter: RENTER1_ID }) - expect(bookings.length).toBeGreaterThan(2) - payload.payLater = true - - payload.booking.agency = testHelper.GetRandromObjectIdAsString() - res = await request(app) - .post('/api/checkout') - .send(payload) - expect(res.statusCode).toBe(204) - - payload.booking.agency = AGENCY_ID - payload.renter = { - fullName: 'renter', - email: testHelper.GetRandomEmail(), - language: testHelper.LANGUAGE, - } - res = await request(app) - .post('/api/checkout') - .send(payload) - expect(res.statusCode).toBe(200) - const renter2 = await User.findOne({ email: payload.renter.email }) - expect(renter2).not.toBeNull() - RENTER2_ID = renter2?.id - const token = await Token.findOne({ user: RENTER2_ID }) - expect(token).not.toBeNull() - expect(token?.token.length).toBeGreaterThan(0) - await token?.deleteOne() - - payload.renter = undefined - res = await request(app) - .post('/api/checkout') - .send(payload) - expect(res.statusCode).toBe(200) - - payload.booking!.property = testHelper.GetRandromObjectIdAsString() - res = await request(app) - .post('/api/checkout') - .send(payload) - expect(res.statusCode).toBe(204) - - payload.booking!.property = PROPERTY1_ID - payload.booking!.location = testHelper.GetRandromObjectIdAsString() - res = await request(app) - .post('/api/checkout') - .send(payload) - expect(res.statusCode).toBe(204) - - payload.booking!.agency = AGENCY_ID - payload.booking!.renter = testHelper.GetRandromObjectIdAsString() - res = await request(app) - .post('/api/checkout') - .send(payload) - expect(res.statusCode).toBe(204) - - res = await request(app) - .post('/api/checkout') - .send({ booking: { renter: RENTER1_ID } }) - expect(res.statusCode).toBe(400) + it('should checkout', async () => { + let bookings = await Booking.find({ renter: RENTER1_ID }) + expect(bookings.length).toBe(1) + + const payload: movininTypes.CheckoutPayload = { + booking: { + agency: AGENCY_ID, + property: PROPERTY1_ID, + renter: RENTER1_ID, + location: LOCATION_ID, + from: new Date(2024, 3, 1), + to: new Date(1990, 3, 4), + status: movininTypes.BookingStatus.Pending, + cancellation: true, + price: 4000, + }, + payLater: true, + } + let res = await request(app) + .post('/api/checkout') + .send(payload) + expect(res.statusCode).toBe(200) + bookings = await Booking.find({ renter: RENTER1_ID }) + expect(bookings.length).toBeGreaterThan(1) + + // Test failed stripe payment + payload.payLater = false + const receiptEmail = testHelper.GetRandomEmail() + const paymentIntentPayload: movininTypes.CreatePaymentIntentPayload = { + amount: 234, + currency: 'usd', + receiptEmail, + customerName: 'John Doe', + description: 'BookCars Booking Service', + } + res = await request(app) + .post('/api/create-payment-intent') + .send(paymentIntentPayload) + expect(res.statusCode).toBe(200) + expect(res.body.paymentIntentId).not.toBeNull() + expect(res.body.customerId).not.toBeNull() + const { paymentIntentId, customerId } = res.body + payload.payLater = false + payload.paymentIntentId = paymentIntentId + payload.customerId = customerId + res = await request(app) + .post('/api/checkout') + .send(payload) + expect(res.statusCode).toBe(400) + + // Test successful stripe payment + await stripeAPI.paymentIntents.confirm(paymentIntentId, { + payment_method: 'pm_card_visa', }) + const renter = await User.findOne({ _id: RENTER1_ID }) + renter!.language = 'fr' + await renter?.save() + res = await request(app) + .post('/api/checkout') + .send(payload) + try { + expect(res.statusCode).toBe(200) + bookings = await Booking.find({ renter: RENTER1_ID }) + expect(bookings.length).toBeGreaterThan(2) + } finally { + const customer = await stripeAPI.customers.retrieve(customerId) + if (customer) { + await stripeAPI.customers.del(customerId) + } + } + payload.payLater = true + + payload.booking!.agency = testHelper.GetRandromObjectIdAsString() + res = await request(app) + .post('/api/checkout') + .send(payload) + expect(res.statusCode).toBe(204) + + payload.booking!.agency = AGENCY_ID + payload.renter = { + fullName: 'renter', + email: testHelper.GetRandomEmail(), + language: testHelper.LANGUAGE, + } + res = await request(app) + .post('/api/checkout') + .send(payload) + expect(res.statusCode).toBe(200) + const renter2 = await User.findOne({ email: payload.renter.email }) + expect(renter2).not.toBeNull() + RENTER2_ID = renter2?.id + const token = await Token.findOne({ user: RENTER2_ID }) + expect(token).not.toBeNull() + expect(token?.token.length).toBeGreaterThan(0) + await token?.deleteOne() + + payload.renter = undefined + res = await request(app) + .post('/api/checkout') + .send(payload) + expect(res.statusCode).toBe(200) + + payload.booking!.property = testHelper.GetRandromObjectIdAsString() + res = await request(app) + .post('/api/checkout') + .send(payload) + expect(res.statusCode).toBe(204) + + payload.booking!.property = PROPERTY1_ID + payload.booking!.location = testHelper.GetRandromObjectIdAsString() + res = await request(app) + .post('/api/checkout') + .send(payload) + expect(res.statusCode).toBe(204) + + payload.booking!.agency = AGENCY_ID + payload.booking!.renter = testHelper.GetRandromObjectIdAsString() + res = await request(app) + .post('/api/checkout') + .send(payload) + expect(res.statusCode).toBe(204) + + res = await request(app) + .post('/api/checkout') + .send({ booking: { renter: RENTER1_ID } }) + expect(res.statusCode).toBe(400) + + payload.booking = undefined + res = await request(app) + .post('/api/checkout') + .send(payload) + expect(res.statusCode).toBe(400) + }) }) describe('POST /api/update-booking', () => { - it('should update a booking', async () => { - const token = await testHelper.signinAsAdmin() - - const payload: movininTypes.Booking = { - _id: BOOKING_ID, - agency: AGENCY_ID, - property: PROPERTY2_ID, - renter: RENTER1_ID, - location: LOCATION_ID, - from: new Date(2024, 2, 1), - to: new Date(1990, 2, 4), - status: movininTypes.BookingStatus.Paid, - cancellation: true, - price: 4800, - } - let res = await request(app) - .put('/api/update-booking') - .set(env.X_ACCESS_TOKEN, token) - .send(payload) - expect(res.statusCode).toBe(200) - expect(res.body.property).toBe(PROPERTY2_ID) - expect(res.body.price).toBe(4800) - expect(res.body.status).toBe(movininTypes.BookingStatus.Paid) - - res = await request(app) - .put('/api/update-booking') - .set(env.X_ACCESS_TOKEN, token) - .send(payload) - expect(res.statusCode).toBe(200) - expect(res.body.property).toBe(PROPERTY2_ID) - expect(res.body.price).toBe(4800) - expect(res.body.status).toBe(movininTypes.BookingStatus.Paid) - - payload._id = testHelper.GetRandromObjectIdAsString() - res = await request(app) - .put('/api/update-booking') - .set(env.X_ACCESS_TOKEN, token) - .send(payload) - expect(res.statusCode).toBe(204) - - // notifyDriver - payload._id = BOOKING_ID - payload.status = movininTypes.BookingStatus.Cancelled - payload.renter = testHelper.GetRandromObjectIdAsString() - res = await request(app) - .put('/api/update-booking') - .set(env.X_ACCESS_TOKEN, token) - .send(payload) - expect(res.statusCode).toBe(200) - - payload.renter = RENTER1_ID - payload.status = movininTypes.BookingStatus.Void - let pushToken = new PushToken({ user: payload.renter, token: 'ExponentPushToken[CokU9KJ9-Yq2ulVTyYOI8J]' }) - await pushToken.save() - res = await request(app) - .put('/api/update-booking') - .set(env.X_ACCESS_TOKEN, token) - .send(payload) - expect(res.statusCode).toBe(200) - await PushToken.deleteOne({ _id: pushToken._id }) - - payload.status = movininTypes.BookingStatus.Deposit - pushToken = new PushToken({ user: payload.renter, token: 'ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]' }) - await pushToken.save() - res = await request(app) - .put('/api/update-booking') - .set(env.X_ACCESS_TOKEN, token) - .send(payload) - expect(res.statusCode).toBe(200) - await PushToken.deleteOne({ _id: pushToken._id }) - - payload.status = movininTypes.BookingStatus.Cancelled - pushToken = new PushToken({ user: payload.renter, token: '0' }) - await pushToken.save() - res = await request(app) - .put('/api/update-booking') - .set(env.X_ACCESS_TOKEN, token) - .send(payload) - expect(res.statusCode).toBe(200) - await PushToken.deleteOne({ _id: pushToken._id }) - - res = await request(app) - .put('/api/update-booking') - .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(400) - - await testHelper.signout(token) - }) + it('should update a booking', async () => { + const token = await testHelper.signinAsAdmin() + + const payload: movininTypes.Booking = { + _id: BOOKING_ID, + agency: AGENCY_ID, + property: PROPERTY2_ID, + renter: RENTER1_ID, + location: LOCATION_ID, + from: new Date(2024, 2, 1), + to: new Date(1990, 2, 4), + status: movininTypes.BookingStatus.Paid, + cancellation: true, + price: 4800, + } + let res = await request(app) + .put('/api/update-booking') + .set(env.X_ACCESS_TOKEN, token) + .send(payload) + expect(res.statusCode).toBe(200) + expect(res.body.property).toBe(PROPERTY2_ID) + expect(res.body.price).toBe(4800) + expect(res.body.status).toBe(movininTypes.BookingStatus.Paid) + + res = await request(app) + .put('/api/update-booking') + .set(env.X_ACCESS_TOKEN, token) + .send(payload) + expect(res.statusCode).toBe(200) + expect(res.body.property).toBe(PROPERTY2_ID) + expect(res.body.price).toBe(4800) + expect(res.body.status).toBe(movininTypes.BookingStatus.Paid) + + payload._id = testHelper.GetRandromObjectIdAsString() + res = await request(app) + .put('/api/update-booking') + .set(env.X_ACCESS_TOKEN, token) + .send(payload) + expect(res.statusCode).toBe(204) + + // notifyDriver + payload._id = BOOKING_ID + payload.status = movininTypes.BookingStatus.Cancelled + payload.renter = testHelper.GetRandromObjectIdAsString() + res = await request(app) + .put('/api/update-booking') + .set(env.X_ACCESS_TOKEN, token) + .send(payload) + expect(res.statusCode).toBe(200) + + payload.renter = RENTER1_ID + payload.status = movininTypes.BookingStatus.Void + let pushToken = new PushToken({ user: payload.renter, token: 'ExponentPushToken[CokU9KJ9-Yq2ulVTyYOI8J]' }) + await pushToken.save() + res = await request(app) + .put('/api/update-booking') + .set(env.X_ACCESS_TOKEN, token) + .send(payload) + expect(res.statusCode).toBe(200) + await PushToken.deleteOne({ _id: pushToken._id }) + + payload.status = movininTypes.BookingStatus.Deposit + pushToken = new PushToken({ user: payload.renter, token: 'ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]' }) + await pushToken.save() + res = await request(app) + .put('/api/update-booking') + .set(env.X_ACCESS_TOKEN, token) + .send(payload) + expect(res.statusCode).toBe(200) + await PushToken.deleteOne({ _id: pushToken._id }) + + payload.status = movininTypes.BookingStatus.Cancelled + pushToken = new PushToken({ user: payload.renter, token: '0' }) + await pushToken.save() + res = await request(app) + .put('/api/update-booking') + .set(env.X_ACCESS_TOKEN, token) + .send(payload) + expect(res.statusCode).toBe(200) + await PushToken.deleteOne({ _id: pushToken._id }) + + res = await request(app) + .put('/api/update-booking') + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(400) + + await testHelper.signout(token) + }) }) describe('POST /api/update-booking-status', () => { - it('should update booking status', async () => { - const token = await testHelper.signinAsAdmin() - - const payload: movininTypes.UpdateStatusPayload = { - ids: [BOOKING_ID], - status: movininTypes.BookingStatus.Reserved, - } - let res = await request(app) - .post('/api/update-booking-status') - .set(env.X_ACCESS_TOKEN, token) - .send(payload) - expect(res.statusCode).toBe(200) - let booking = await Booking.findById(BOOKING_ID) - expect(booking?.status).toBe(movininTypes.BookingStatus.Reserved) - - res = await request(app) - .post('/api/update-booking-status') - .set(env.X_ACCESS_TOKEN, token) - .send(payload) - expect(res.statusCode).toBe(200) - booking = await Booking.findById(BOOKING_ID) - expect(booking?.status).toBe(movininTypes.BookingStatus.Reserved) - - res = await request(app) - .post('/api/update-booking-status') - .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(400) - - await testHelper.signout(token) - }) + it('should update booking status', async () => { + const token = await testHelper.signinAsAdmin() + + const payload: movininTypes.UpdateStatusPayload = { + ids: [BOOKING_ID], + status: movininTypes.BookingStatus.Reserved, + } + let res = await request(app) + .post('/api/update-booking-status') + .set(env.X_ACCESS_TOKEN, token) + .send(payload) + expect(res.statusCode).toBe(200) + let booking = await Booking.findById(BOOKING_ID) + expect(booking?.status).toBe(movininTypes.BookingStatus.Reserved) + + res = await request(app) + .post('/api/update-booking-status') + .set(env.X_ACCESS_TOKEN, token) + .send(payload) + expect(res.statusCode).toBe(200) + booking = await Booking.findById(BOOKING_ID) + expect(booking?.status).toBe(movininTypes.BookingStatus.Reserved) + + res = await request(app) + .post('/api/update-booking-status') + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(400) + + await testHelper.signout(token) + }) }) describe('GET /api/booking/:id/:language', () => { - it('should get a booking', async () => { - const token = await testHelper.signinAsAdmin() - - let res = await request(app) - .get(`/api/booking/${BOOKING_ID}/${testHelper.LANGUAGE}`) - .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(200) - expect(res.body.property._id).toBe(PROPERTY2_ID) - - res = await request(app) - .get(`/api/booking/${testHelper.GetRandromObjectIdAsString()}/${testHelper.LANGUAGE}`) - .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(204) - - res = await request(app) - .get(`/api/booking/${uuid()}/${testHelper.LANGUAGE}`) - .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(400) - - await testHelper.signout(token) - }) + it('should get a booking', async () => { + const token = await testHelper.signinAsAdmin() + + let res = await request(app) + .get(`/api/booking/${BOOKING_ID}/${testHelper.LANGUAGE}`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(200) + expect(res.body.property._id).toBe(PROPERTY2_ID) + + res = await request(app) + .get(`/api/booking/${testHelper.GetRandromObjectIdAsString()}/${testHelper.LANGUAGE}`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(204) + + res = await request(app) + .get(`/api/booking/${uuid()}/${testHelper.LANGUAGE}`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(400) + + await testHelper.signout(token) + }) }) describe('POST /api/bookings/:page/:size/:language', () => { - it('should get bookings', async () => { - const token = await testHelper.signinAsAdmin() - - const payload: movininTypes.GetBookingsPayload = { - agencies: [AGENCY_ID], - statuses: [movininTypes.BookingStatus.Reserved], - filter: { - location: LOCATION_ID, - from: new Date(2024, 2, 1), - to: new Date(1990, 2, 4), - keyword: testHelper.USER_FULL_NAME, - }, - user: testHelper.getUserId(), - property: PROPERTY2_ID, - } - let res = await request(app) - .post(`/api/bookings/${testHelper.PAGE}/${testHelper.SIZE}/${testHelper.LANGUAGE}`) - .set(env.X_ACCESS_TOKEN, token) - .send(payload) - expect(res.statusCode).toBe(200) - expect(res.body[0].resultData.length).toBe(1) - - payload.user = undefined - payload.property = undefined - payload.filter!.from = undefined - payload.filter!.to = undefined - payload.filter!.location = undefined - payload.filter!.keyword = undefined - res = await request(app) - .post(`/api/bookings/${testHelper.PAGE}/${testHelper.SIZE}/${testHelper.LANGUAGE}`) - .set(env.X_ACCESS_TOKEN, token) - .send(payload) - expect(res.statusCode).toBe(200) - expect(res.body[0].resultData.length).toBe(1) - - payload.filter!.keyword = BOOKING_ID - res = await request(app) - .post(`/api/bookings/${testHelper.PAGE}/${testHelper.SIZE}/${testHelper.LANGUAGE}`) - .set(env.X_ACCESS_TOKEN, token) - .send(payload) - expect(res.statusCode).toBe(200) - expect(res.body[0].resultData.length).toBe(1) - - res = await request(app) - .post(`/api/bookings/${testHelper.PAGE}/${testHelper.SIZE}/${testHelper.LANGUAGE}`) - .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(400) - - await testHelper.signout(token) - }) + it('should get bookings', async () => { + const token = await testHelper.signinAsAdmin() + + const payload: movininTypes.GetBookingsPayload = { + agencies: [AGENCY_ID], + statuses: [movininTypes.BookingStatus.Reserved], + filter: { + location: LOCATION_ID, + from: new Date(2024, 2, 1), + to: new Date(1990, 2, 4), + keyword: testHelper.USER_FULL_NAME, + }, + user: testHelper.getUserId(), + property: PROPERTY2_ID, + } + let res = await request(app) + .post(`/api/bookings/${testHelper.PAGE}/${testHelper.SIZE}/${testHelper.LANGUAGE}`) + .set(env.X_ACCESS_TOKEN, token) + .send(payload) + expect(res.statusCode).toBe(200) + expect(res.body[0].resultData.length).toBe(1) + + payload.user = undefined + payload.property = undefined + payload.filter!.from = undefined + payload.filter!.to = undefined + payload.filter!.location = undefined + payload.filter!.keyword = undefined + res = await request(app) + .post(`/api/bookings/${testHelper.PAGE}/${testHelper.SIZE}/${testHelper.LANGUAGE}`) + .set(env.X_ACCESS_TOKEN, token) + .send(payload) + expect(res.statusCode).toBe(200) + expect(res.body[0].resultData.length).toBe(1) + + payload.filter!.keyword = BOOKING_ID + res = await request(app) + .post(`/api/bookings/${testHelper.PAGE}/${testHelper.SIZE}/${testHelper.LANGUAGE}`) + .set(env.X_ACCESS_TOKEN, token) + .send(payload) + expect(res.statusCode).toBe(200) + expect(res.body[0].resultData.length).toBe(1) + + res = await request(app) + .post(`/api/bookings/${testHelper.PAGE}/${testHelper.SIZE}/${testHelper.LANGUAGE}`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(400) + + await testHelper.signout(token) + }) }) describe('GET /api/has-bookings/:renter', () => { - it("should check renter's bookings", async () => { - const token = await testHelper.signinAsAdmin() - - let res = await request(app) - .get(`/api/has-bookings/${RENTER1_ID}`) - .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(200) - - res = await request(app) - .get(`/api/has-bookings/${AGENCY_ID}`) - .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(204) - const booking = await Booking.findById(BOOKING_ID) - expect(booking?.status).toBe(movininTypes.BookingStatus.Reserved) - - res = await request(app) - .get(`/api/has-bookings/${uuid()}`) - .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(400) - - await testHelper.signout(token) - }) + it("should check renter's bookings", async () => { + const token = await testHelper.signinAsAdmin() + + let res = await request(app) + .get(`/api/has-bookings/${RENTER1_ID}`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(200) + + res = await request(app) + .get(`/api/has-bookings/${AGENCY_ID}`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(204) + const booking = await Booking.findById(BOOKING_ID) + expect(booking?.status).toBe(movininTypes.BookingStatus.Reserved) + + res = await request(app) + .get(`/api/has-bookings/${uuid()}`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(400) + + await testHelper.signout(token) + }) }) describe('POST /api/cancel-booking/:id', () => { - it('should cancel a booking', async () => { - const token = await testHelper.signinAsUser() - - let booking = await Booking.findById(BOOKING_ID) - expect(booking?.cancelRequest).toBeFalsy() - let res = await request(app) - .post(`/api/cancel-booking/${BOOKING_ID}`) - .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(200) - booking = await Booking.findById(BOOKING_ID) - expect(booking?.cancelRequest).toBeTruthy() - - res = await request(app) - .post(`/api/cancel-booking/${testHelper.GetRandromObjectIdAsString()}`) - .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(204) - - res = await request(app) - .post(`/api/cancel-booking/${uuid()}`) - .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(400) - - await testHelper.signout(token) - }) + it('should cancel a booking', async () => { + const token = await testHelper.signinAsUser() + + let booking = await Booking.findById(BOOKING_ID) + expect(booking?.cancelRequest).toBeFalsy() + let res = await request(app) + .post(`/api/cancel-booking/${BOOKING_ID}`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(200) + booking = await Booking.findById(BOOKING_ID) + expect(booking?.cancelRequest).toBeTruthy() + + res = await request(app) + .post(`/api/cancel-booking/${testHelper.GetRandromObjectIdAsString()}`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(204) + + res = await request(app) + .post(`/api/cancel-booking/${uuid()}`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(400) + + await testHelper.signout(token) + }) }) describe('DELETE /api/delete-bookings', () => { - it('should delete bookings', async () => { - const token = await testHelper.signinAsAdmin() - - const renters = [RENTER1_ID, RENTER2_ID] - - let bookings = await Booking.find({ renter: { $in: renters } }) - expect(bookings.length).toBeGreaterThan(0) - const payload: string[] = bookings.map((u) => u.id) - let res = await request(app) - .post('/api/delete-bookings') - .set(env.X_ACCESS_TOKEN, token) - .send(payload) - expect(res.statusCode).toBe(200) - bookings = await Booking.find({ renter: { $in: renters } }) - expect(bookings.length).toBe(0) - - res = await request(app) - .post('/api/delete-bookings') - .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(400) - - await testHelper.signout(token) - }) + it('should delete bookings', async () => { + const token = await testHelper.signinAsAdmin() + + const renters = [RENTER1_ID, RENTER2_ID] + + let bookings = await Booking.find({ renter: { $in: renters } }) + expect(bookings.length).toBeGreaterThan(0) + const payload: string[] = bookings.map((u) => u.id) + let res = await request(app) + .post('/api/delete-bookings') + .set(env.X_ACCESS_TOKEN, token) + .send(payload) + expect(res.statusCode).toBe(200) + bookings = await Booking.find({ renter: { $in: renters } }) + expect(bookings.length).toBe(0) + + res = await request(app) + .post('/api/delete-bookings') + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(400) + + await testHelper.signout(token) + }) }) diff --git a/api/tests/helper.test.ts b/api/tests/helper.test.ts index cd8f1123..d7e216b9 100644 --- a/api/tests/helper.test.ts +++ b/api/tests/helper.test.ts @@ -23,3 +23,9 @@ describe('Test clone', () => { expect(helper.clone([1, 2, 3])).toStrictEqual([1, 2, 3]) }) }) + +describe('Test trim', () => { + it('should test trim', () => { + expect(helper.trim(' xxxxxxxx ', ' ')).toBe('xxxxxxxx') + }) +}) diff --git a/api/tests/stripe.test.ts b/api/tests/stripe.test.ts new file mode 100644 index 00000000..ac6114c6 --- /dev/null +++ b/api/tests/stripe.test.ts @@ -0,0 +1,56 @@ +import 'dotenv/config' +import request from 'supertest' +import * as movininTypes from ':movinin-types' +import app from '../src/app' +import * as testHelper from './testHelper' +import stripeAPI from '../src/stripe' + +describe('POST /api/create-payment-intent', () => { + it('should create payment intents', async () => { + // Test create payment intent whith non existant user + const receiptEmail = testHelper.GetRandomEmail() + const payload: movininTypes.CreatePaymentIntentPayload = { + amount: 234, + currency: 'usd', + receiptEmail, + customerName: 'John Doe', + } + let res = await request(app) + .post('/api/create-payment-intent') + .send(payload) + expect(res.statusCode).toBe(200) + expect(res.body.paymentIntentId).not.toBeNull() + expect(res.body.customerId).not.toBeNull() + + // Test create payment intent whith existant user + const paymentIntent = await stripeAPI.paymentIntents.create({ + amount: payload.amount, + currency: payload.currency, + receipt_email: receiptEmail, + }) + expect(paymentIntent).not.toBeNull() + try { + res = await request(app) + .post('/api/create-payment-intent') + .send(payload) + expect(res.statusCode).toBe(200) + expect(res.body.paymentIntentId).not.toBeNull() + expect(res.body.customerId).not.toBeNull() + } finally { + const customers = await stripeAPI.customers.list({ email: receiptEmail }) + if (customers.data.length > 0) { + for (const customer of customers.data) { + await stripeAPI.customers.del(customer.id) + } + } + } + + // Test create payment intent failure + payload.receiptEmail = 'xxxxxxxxxxxxxxx' + res = await request(app) + .post('/api/create-payment-intent') + .send(payload) + expect(res.statusCode).toBe(400) + expect(res.body).toStrictEqual({}) + }) +}) diff --git a/frontend/.env.example b/frontend/.env.example index aeed3c69..90da5547 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,16 +1,17 @@ -PORT = 3004 -REACT_APP_NODE_ENV = development -REACT_APP_MI_API_HOST = http://localhost:4004 -REACT_APP_MI_DEFAULT_LANGUAGE = en -REACT_APP_MI_PAGE_SIZE = 30 -REACT_APP_MI_PROPERTIES_PAGE_SIZE = 15 -REACT_APP_MI_BOOKINGS_PAGE_SIZE = 20 -REACT_APP_MI_BOOKINGS_MOBILE_PAGE_SIZE = 10 -REACT_APP_MI_CDN_USERS = http://localhost/cdn/movinin/users -REACT_APP_MI_CDN_PROPERTIES = http://localhost/cdn/movinin/properties -REACT_APP_MI_AGENCY_IMAGE_WIDTH = 60 -REACT_APP_MI_AGENCY_IMAGE_HEIGHT = 30 -REACT_APP_MI_PROPERTY_IMAGE_WIDTH = 300 -REACT_APP_MI_PROPERTY_IMAGE_HEIGHT = 200 -REACT_APP_MI_MINIMUM_AGE = 21 -REACT_APP_MI_PAGINATION_MODE = classic +PORT=3004 +REACT_APP_NODE_ENV=development +REACT_APP_MI_API_HOST=http://localhost:4004 +REACT_APP_MI_DEFAULT_LANGUAGE=en +REACT_APP_MI_PAGE_SIZE=30 +REACT_APP_MI_PROPERTIES_PAGE_SIZE=15 +REACT_APP_MI_BOOKINGS_PAGE_SIZE=20 +REACT_APP_MI_BOOKINGS_MOBILE_PAGE_SIZE=10 +REACT_APP_MI_CDN_USERS=http://localhost/cdn/movinin/users +REACT_APP_MI_CDN_PROPERTIES=http://localhost/cdn/movinin/properties +REACT_APP_MI_AGENCY_IMAGE_WIDTH=60 +REACT_APP_MI_AGENCY_IMAGE_HEIGHT=30 +REACT_APP_MI_PROPERTY_IMAGE_WIDTH=300 +REACT_APP_MI_PROPERTY_IMAGE_HEIGHT=200 +REACT_APP_MI_MINIMUM_AGE=21 +REACT_APP_MI_PAGINATION_MODE=classic +REACT_APP_MI_STRIPE_PUBLISHABLE_KEY=STRIPE_PUBLISHABLE_KEY diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 72b51aa2..8e50f2ed 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,8 @@ "@mui/material": "^5.15.15", "@mui/x-data-grid": "^7.3.0", "@mui/x-date-pickers": "^7.2.0", + "@stripe/react-stripe-js": "^2.7.0", + "@stripe/stripe-js": "^3.3.0", "@types/node": "^20.12.7", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", @@ -4533,6 +4535,27 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@stripe/react-stripe-js": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.7.0.tgz", + "integrity": "sha512-kTkIZl2ZleBuDR9c6fDy/s4m33llII8a5al6BDAMSTrfVq/4gSZv3RBO5KS/xvnxS+fDapJ3bKvjD8Lqj+AKdQ==", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-3.3.0.tgz", + "integrity": "sha512-dUgAsko9KoYC1U2TIawHzbkQJzPoApxCc1Qf6/j318d1ArViyh6ROHVYTxnU3RlOQL/utUD9I4/QoyiCowsgrw==", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 85225210..94908cb1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,8 @@ "@mui/material": "^5.15.15", "@mui/x-data-grid": "^7.3.0", "@mui/x-date-pickers": "^7.2.0", + "@stripe/react-stripe-js": "^2.7.0", + "@stripe/stripe-js": "^3.3.0", "@types/node": "^20.12.7", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 85a6d3da..a1394661 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,9 @@ import React, { lazy, Suspense } from 'react' import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' +import { Elements } from '@stripe/react-stripe-js' +import { loadStripe } from '@stripe/stripe-js' import { GlobalProvider } from './context/GlobalContext' +import env from './config/env.config' const SignIn = lazy(() => import('./pages/SignIn')) const SignUp = lazy(() => import('./pages/SignUp')) @@ -21,35 +24,41 @@ const ChangePassword = lazy(() => import('./pages/ChangePassword')) const Contact = lazy(() => import('./pages/Contact')) const NoMatch = lazy(() => import('./pages/NoMatch')) +// Make sure to call `loadStripe` outside of a component’s render to avoid +// recreating the `Stripe` object on every render. +const stripePromise = loadStripe(env.STRIPE_PUBLISHABLE_KEY as string) + const App = () => ( - -
- }> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + +
+ }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - } /> - - -
-
+ } /> +
+
+
+
+
) diff --git a/frontend/src/assets/css/checkout.css b/frontend/src/assets/css/checkout.css index 5e597134..a2145042 100644 --- a/frontend/src/assets/css/checkout.css +++ b/frontend/src/assets/css/checkout.css @@ -43,7 +43,7 @@ div.booking div.payment-options span.payment-info { font-size: 13px; } -@media only screen and (width <= 960px) { +@media only screen and (width <=960px) { div.booking div.payment-options span.payment-button { display: flex; flex-direction: column; @@ -184,11 +184,15 @@ div.booking div.payment div.cost div.secure-payment-cost span.cost-value { div.booking div.payment div.secure-payment-logo { width: 100%; - height: 40px; display: flex; justify-content: center; align-items: center; margin-bottom: 5px; + margin-top: 15px; +} + +div.booking div.payment div.secure-payment-logo img { + max-width: 100%; } div.booking div.booking-buttons { @@ -201,7 +205,7 @@ div.booking div.booking-buttons a { float: right; } -div.booking .btn-action, +div.booking .btn-checkout, div.booking .btn-cancel { font-size: 18px; line-height: 24px; @@ -210,14 +214,14 @@ div.booking .btn-cancel { border-radius: 5px; color: #fff; height: 42px; - min-width: 75px; + min-width: 164px; margin: 0; white-space: nowrap; padding: 5px 32px; margin-left: 7px; } -div.booking .btn-action { +div.booking .btn-checkout { background: #0D63C9; } @@ -225,9 +229,24 @@ div.booking .btn-cancel { background: #999; } +div.booking .stripe-card { + margin: 0; +} + +div.booking .card-number { + margin-top: 2rem; +} + +div.booking .card-element { + border: 1px solid rgba(0, 0, 0, 0.23); + border-radius: 4px; + padding: 17px; + margin: 0; +} + /* Device width is less than or equal to 960px */ -@media only screen and (width <= 960px) { +@media only screen and (width <=960px) { div.booking .booking-form { padding: 30px; margin: 10px; @@ -251,7 +270,7 @@ div.booking .btn-cancel { /* Device width is greater than or equal to 960px */ -@media only screen and (width >= 960px) { +@media only screen and (width >=960px) { div.booking .booking-form { margin: 45px 0 20px calc(50% - 350px); width: 700px; diff --git a/frontend/src/assets/img/secure-payment.png b/frontend/src/assets/img/secure-payment.png index aea9f486cb2c03fff7cf651e4291a514c06e412e..425d63424024a4db0bcd5217896d1b2a9b084ee1 100644 GIT binary patch literal 17049 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 { const [tosChecked, setTosChecked] = useState(false) const [tosError, setTosError] = useState(false) const [error, setError] = useState(false) - const [cardName, setCardName] = useState('') - const [cardNumber, setCardNumber] = useState('') const [cardNumberValid, setCardNumberValid] = useState(true) - const [cardMonth, setCardMonth] = useState('') - const [cardMonthValid, setCardMonthValid] = useState(true) - const [cardYear, setcardYear] = useState('') - const [cardYearValid, setCardYearValid] = useState(true) - const [cvv, setCvv] = useState('') + const [cardExpiryValid, setCardExpiryValid] = useState(true) const [cvvValid, setCvvValid] = useState(true) const [price, setPrice] = useState(0) const [emailInfo, setEmailInfo] = useState(true) const [phoneInfo, setPhoneInfo] = useState(true) const [cancellation, setCancellation] = useState(false) - const [cardDateError, setCardDateError] = useState(false) const [success, setSuccess] = useState(false) const [loading, setLoading] = useState(false) - const [payLater, setPayLater] = useState(false) + const stripe = useStripe() + const elements = useElements() + const [paymentFailed, setPaymentFailed] = useState(false) + const handleCancellationChange = (e: React.ChangeEvent) => { if (property && from && to) { const _cancellation = e.target.checked @@ -200,140 +208,6 @@ const Checkout = () => { } } - const validateCardNumber = (_cardNumber?: string) => { - if (_cardNumber) { - const _cardNumberValid = validator.isCreditCard(_cardNumber) - setCardNumberValid(_cardNumberValid) - - return _cardNumberValid - } - setCardNumberValid(true) - - return true - } - - const handleCardNumberBlur = (e: React.FocusEvent) => { - validateCardNumber(e.target.value) - } - - const handleCardNumberChange = (e: React.ChangeEvent) => { - setCardNumber(e.target.value) - - if (!e.target.value) { - setCardNumberValid(true) - } - } - - const validateCardMonth = (_cardMonth?: string) => { - if (_cardMonth) { - if (movininHelper.isInteger(_cardMonth)) { - const month = Number.parseInt(_cardMonth, 10) - const _cardMonthValid = month >= 1 && month <= 12 - - setCardMonthValid(_cardMonthValid) - setCardDateError(false) - - return _cardMonthValid - } - setCardMonthValid(false) - setCardDateError(false) - - return false - } - setCardMonthValid(true) - setCardDateError(false) - - return true - } - - const handleCardMonthBlur = (e: React.FocusEvent) => { - validateCardMonth(e.target.value) - } - - const handleCardMonthChange = (e: React.ChangeEvent) => { - setCardMonth(e.target.value) - - if (!e.target.value) { - setCardMonthValid(true) - setCardDateError(false) - } - } - - const validateCardYear = (_cardYear?: string) => { - if (_cardYear) { - if (movininHelper.isYear(_cardYear)) { - const year = Number.parseInt(_cardYear, 10) - const currentYear = Number.parseInt(String(new Date().getFullYear()).slice(2), 10) - const _cardYearValid = year >= currentYear - - setCardYearValid(_cardYearValid) - setCardDateError(false) - - return _cardYearValid - } - setCardYearValid(false) - setCardDateError(false) - - return false - } - setCardYearValid(true) - setCardDateError(false) - - return true - } - - const handleCardYearBlur = (e: React.FocusEvent) => { - validateCardYear(e.target.value) - } - - const handleCardYearChange = (e: React.ChangeEvent) => { - setcardYear(e.target.value) - - if (!e.target.value) { - setCardYearValid(true) - setCardDateError(false) - } - } - - const validateCvv = (_cvv?: string) => { - if (_cvv) { - const _cvvValid = movininHelper.isCvv(_cvv) - setCvvValid(_cvvValid) - - return _cvvValid - } - setCvvValid(true) - - return true - } - - const handleCvvBlur = (e: React.FocusEvent) => { - validateCvv(e.target.value) - } - - const handleCvvChange = (e: React.ChangeEvent) => { - setCvv(e.target.value) - - if (!e.target.value) { - setCvvValid(true) - } - } - - const validateCardDate = (_cardMonth: string, _cardYear: string) => { - const today = new Date() - const cardDate = new Date() - const y = Number.parseInt(String(today.getFullYear()).slice(0, 2), 10) * 100 - const year = y + Number.parseInt(_cardYear, 10) - const month = Number.parseInt(_cardMonth, 10) - cardDate.setFullYear(year, month - 1, 1) - - if (cardDate < today) { - return false - } - - return true - } - const handleSubmit = async (e: React.FormEvent) => { try { e.preventDefault() @@ -365,39 +239,35 @@ const Checkout = () => { } } + let card: StripeCardNumberElement | null = null if (!payLater) { - if (cardName && cardName.length < 1) { + if (!cardNumberValid) { return } - const _cardNumberValid = validateCardNumber(cardNumber) - if (!_cardNumberValid) { + if (!cardExpiryValid) { return } - const _cardMonthValid = validateCardMonth(cardMonth) - if (!_cardMonthValid) { + if (!cvvValid) { return } - const _cardYearValid = validateCardYear(cardYear) - if (!_cardYearValid) { + if (!stripe || !elements) { + // Stripe.js hasn't yet loaded. return } - const _cvvValid = validateCvv(cvv) - if (!_cvvValid) { - return - } + card = elements.getElement(CardNumberElement) - const cardDateValid = validateCardDate(cardMonth, cardYear) - if (!cardDateValid) { - setCardDateError(true) + if (!card) { + // CardNumberElement hasn't yet loaded. return } } setLoading(true) + setPaymentFailed(false) let renter: movininTypes.User | undefined @@ -418,22 +288,77 @@ const Checkout = () => { location: location._id, from, to, - status: payLater ? movininTypes.BookingStatus.Pending : movininTypes.BookingStatus.Paid, + status: movininTypes.BookingStatus.Pending, cancellation, price, } + // + // Stripe Payment Gateway + // + let paid = payLater + let validationError = false + let paymentIntentId: string | undefined + let customerId: string | undefined + + if (!payLater) { + const createPaymentIntentPayload: movininTypes.CreatePaymentIntentPayload = { + amount: price, + // Supported currencies for the moment: usd, eur + // Must be a supported currency: https://docs.stripe.com/currencies + currency: commonStrings.CURRENCY === '$' ? 'usd' : commonStrings.CURRENCY === '€' ? 'eur' : '', + receiptEmail: (!authenticated ? renter?.email : user?.email) as string, + description: "Movin' In Booking Service", + customerName: (!authenticated ? renter?.fullName : user?.fullName) as string, + } + + // Create payment intent + const { + paymentIntentId: stripePaymentIntentId, + clientSecret, + customerId: stripeCustomerId, + } = await StripeService.createPaymentIntent(createPaymentIntentPayload) + paymentIntentId = stripePaymentIntentId || undefined + customerId = stripeCustomerId || undefined + + if (clientSecret) { + const paymentPayload = await stripe?.confirmCardPayment(clientSecret, { + payment_method: { + card: card as StripeCardNumberElement, + } + }) + + validationError = paymentPayload?.error?.type === 'validation_error' + paid = !paymentPayload?.error + } else { + paid = false + } + } + + if (validationError) { + // Card Validation Error + setLoading(false) + return + } + + if (!paid) { + // Payment failed + setLoading(false) + setPaymentFailed(true) + return + } + const payload: movininTypes.CheckoutPayload = { renter, booking, payLater, + paymentIntentId, + customerId } const status = await BookingService.checkout(payload) if (status === 200) { - window.history.replaceState({}, window.document.title, '/checkout') - setLoading(false) setVisible(false) setSuccess(true) @@ -504,6 +429,24 @@ const Checkout = () => { const _format = _fr ? 'eee d LLL yyyy kk:mm' : 'eee, d LLL yyyy, p' const bookingDetailHeight = env.AGENCY_IMAGE_HEIGHT + 10 + const cardStyle: StripeCardNumberElementOptions = { + style: { + base: { + color: 'rgba(0, 0, 0, 0.87)', + fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol'", + fontSmoothing: 'antialiased', + fontSize: '16px', + '::placeholder': { + color: '#606060', + }, + }, + invalid: { + color: '#d32f2f', + iconColor: '#d32f2f' + } + } + } + return ( {visible && property && from && to && location && ( @@ -709,78 +652,41 @@ const Checkout = () => { -
- -
-
- {strings.CARD_NAME} - { - setCardName(e.target.value) - }} - required - autoComplete="off" - /> - - - {strings.CARD_NUMBER} - +
+ { + setCardNumberValid(!e.error) + }} + /> +
{(!cardNumberValid && strings.CARD_NUMBER_NOT_VALID) || ''}
-
- - {strings.CARD_MONTH} - +
+ { + setCardExpiryValid(!e.error) + }} /> - {(!cardMonthValid && strings.CARD_MONTH_NOT_VALID) || ''} - - - {strings.CARD_YEAR} - - {(!cardYearValid && strings.CARD_YEAR_NOT_VALID) || ''} - -
+
+ {(!cardExpiryValid && strings.CARD_EXPIRY_NOT_VALID) || ''} + - {strings.CVV} - +
+ { + setCvvValid(!e.error) + }} + /> +
{(!cvvValid && strings.CVV_NOT_VALID) || ''}
@@ -789,11 +695,20 @@ const Checkout = () => { {strings.SECURE_PAYMENT_INFO} + +
+ +
+ )}
-
- {cardDateError && } {tosError && } {error && } + {paymentFailed && }
@@ -811,7 +726,6 @@ const Checkout = () => { )} {noMatch && } {success && } - {loading && }
) } diff --git a/frontend/src/services/StripeService.ts b/frontend/src/services/StripeService.ts new file mode 100644 index 00000000..f51f4738 --- /dev/null +++ b/frontend/src/services/StripeService.ts @@ -0,0 +1,16 @@ +import * as movininTypes from ':movinin-types' +import axiosInstance from './axiosInstance' + +/** + * Create Payment Intent + * + * @param {movininTypes.CreatePaymentIntentPayload} payload + * @returns {Promise} + */ +export const createPaymentIntent = (payload: movininTypes.CreatePaymentIntentPayload): Promise => + axiosInstance + .post( + '/api/create-payment-intent', + payload + ) + .then((res) => res.data) diff --git a/packages/movinin-types/index.ts b/packages/movinin-types/index.ts index cd8c3f3b..4a209ea3 100644 --- a/packages/movinin-types/index.ts +++ b/packages/movinin-types/index.ts @@ -133,8 +133,10 @@ export interface Booking { export interface CheckoutPayload { renter?: User - booking: Booking + booking?: Booking payLater?: boolean + paymentIntentId?: string + customerId?: string } export interface Filter { @@ -219,6 +221,7 @@ export interface User { payLater?: boolean accessToken?: string checked?: boolean + customerId?: string } export interface Option { @@ -358,6 +361,25 @@ export interface PropertyOptions { cancellation?: boolean } +export interface CreatePaymentIntentPayload { + amount: number + /** + * Three-letter ISO currency code, in lowercase. + * Must be a supported currency: https://docs.stripe.com/currencies + * + * @type {string} + */ + currency: string + receiptEmail: string + description?: string + customerName: string +} + +export interface PaymentIntentResult { + paymentIntentId: string + customerId: string | null + clientSecret: string | null +} // // React types