Skip to content

Commit

Permalink
Merge pull request #14 from NIAEFEUP/1-buy-early-bird-ticket-flow
Browse files Browse the repository at this point in the history
Feature: payments
  • Loading branch information
limwa authored Feb 5, 2025
2 parents bb38955 + 4ed0c07 commit fb85d50
Show file tree
Hide file tree
Showing 46 changed files with 2,073 additions and 421 deletions.
14 changes: 13 additions & 1 deletion docker-compose.coolify.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,23 @@ services:
- "LOG_LEVEL=${LOG_LEVEL:-info}"
- "APP_KEY=${APP_KEY}"
- "SESSION_DRIVER=${SESSION_DRIVER:-cookie}"
- "REDIS_HOST=${REDIS_HOST:-valkey}"
- "REDIS_PORT=${REDIS_PORT:-6379}"
- "REDIS_PASSWORD=${REDIS_PASSWORD}"
- "FROM_EMAIL=${FROM_EMAIL:[email protected]}"
- "SMTP_HOST=${SMTP_HOST}"
- "SMTP_PORT=${SMTP_PORT}"
- "INERTIA_PUBLIC_TZ=${INERTIA_PUBLIC_TZ:-Europe/Lisbon}"
- "INERTIA_PUBLIC_EVENT_COUNTDOWN_DATE=${INERTIA_PUBLIC_EVENT_COUNTDOWN_DATE:-2025-04-11}"

valkey:
image: valkey/valkey:8-alpine
command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"]
volumes:
- valkey-data:/data
environment:
- "VALKEY_EXTRA_FLAGS=${VALKEY_EXTRA_FLAGS}"

volumes:
website-tmp:
website-tmp:
valkey-data:
13 changes: 12 additions & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,15 @@ services:
container_name: enei-mailpit
ports:
- "1025:1025"
- "8025:8025"
- "8025:8025"

valkey:
image: valkey/valkey:8-alpine
container_name: enei-valkey
volumes:
- valkey-data:/data
ports:
- "6379:6379"

volumes:
valkey-data:
6 changes: 6 additions & 0 deletions package-lock.json

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

8 changes: 8 additions & 0 deletions website/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@ NODE_ENV=development
# Public facing app environment variables
APP_KEY=

# Payments
IFTHENPAY_MBWAY_KEY=

# Session
SESSION_DRIVER=cookie

# Jobs
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=

# E-mail
FROM_EMAIL=[email protected]
REPLY_TO_EMAIL=[email protected]
Expand Down
2 changes: 2 additions & 0 deletions website/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,5 @@ yarn-error.log

# Platform specific
.DS_Store

dump.rdb
2 changes: 2 additions & 0 deletions website/adonisrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default defineConfig({
() => import('@adonisjs/lucid/commands'),
() => import('@adonisjs/mail/commands'),
() => import('@tuyau/core/commands'),
() => import('adonisjs-jobs/commands'),
],

/*
Expand Down Expand Up @@ -43,6 +44,7 @@ export default defineConfig({
() => import('@adonisjs/lucid/database_provider'),
() => import('@adonisjs/auth/auth_provider'),
() => import('@adonisjs/inertia/inertia_provider'),
() => import('adonisjs-jobs/jobs_provider'),
() => import('@adonisjs/mail/mail_provider'),
() => import('@tuyau/core/tuyau_provider'),
() => import('@adonisjs/ally/ally_provider'),
Expand Down
180 changes: 180 additions & 0 deletions website/app/controllers/orders_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import type { HttpContext } from '@adonisjs/core/http'
import env from '#start/env'
import axios from 'axios'
import Order from '#models/order'
import User from '#models/user'
import OrderProduct from '#models/order_product'
import Product from '#models/product'
import ProductGroup from '#models/product_group'
import { createMBWayOrderValidator } from '#validators/order'
import UpdateOrderStatus from '../jobs/update_order_status.js'
export default class OrdersController {
index({ inertia }: HttpContext) {
return inertia.render('payments/index')
}

public async createMBWay({ request, auth, response }: HttpContext) {
const authUser = auth.user

try {
// Validate input format
await request.validateUsing(createMBWayOrderValidator)

const { userId, products, name, nif, address, mobileNumber } = request.all()

// Validate authentication

if (!authUser || authUser.id !== userId) {
return response.status(401).json({ message: 'Não autorizado' })
}

// Validate user existence

const user = await User.find(userId)

if (!user) {
return response.status(404).json({ message: 'Utilizador não encontrado' })
}

let totalAmount = 0
let description = ''

const productDetails = []

for (const productItem of products) {
const { productId, quantity } = productItem
const product = await Product.find(productId)

if (!product) {
return response
.status(404)
.json({ message: `Produto com id ${productId} não foi encontrado` })
}

const successfulOrdersOfGivenProduct = await OrderProduct.query()
.join('orders', 'order_products.order_id', 'orders.id')
.where('orders.user_id', userId)
.where('order_products.product_id', productId)
.where('orders.status', 'Success')



const totalQuantity = successfulOrdersOfGivenProduct.reduce(
(acc, orderProduct) => acc + orderProduct.quantity,
0
)

if (product.stock < quantity) {
return response
.status(400)
.json({ message: `Não há mais stock do produto ${product.name}` })
}

if (quantity + totalQuantity > product.max_order) {
return response.status(400).json({
message: `Apenas podes comprar ${product.max_order} do produto ${product.name}`,
})
}

const productGroup = await ProductGroup.find(product.productGroupId)
if(productGroup){

const sucessfulOrdersOfGivenGroup = await OrderProduct.query()
.join('orders', 'order_products.order_id', 'orders.id')
.join('products', 'order_products.product_id', 'products.id')
.where('orders.user_id', userId)
.where('products.product_group_id', product.productGroupId)
.where('orders.status', 'Success')



const totalGroupQuantity = sucessfulOrdersOfGivenGroup.reduce(
(acc, orderProduct) => acc + orderProduct.quantity,
0
)

if (totalGroupQuantity + quantity > productGroup.maxAmountPerGroup) {
return response.status(400).json({
message: `Apenas podes comprar ${productGroup?.maxAmountPerGroup} produtos do grupo ${productGroup.name}`,
})

}
}
productDetails.push({ product, quantity })
totalAmount += product.price * quantity
description += `${product.name} x${quantity}, `
}

description = `Payment for order: ${description.slice(0, -2)}`

// Create the order and associated products
const order = await Order.create({ userId, name, nif, address })

for (const { product, quantity } of productDetails) {
await OrderProduct.create({
orderId: order.id,
productId: product.id,
quantity,
})
}

// Prepare payment data

const data = {
mbWayKey: env.get('IFTHENPAY_MBWAY_KEY'),
orderId: order.id,
amount: totalAmount.toFixed(2),
mobileNumber,
description,
}

// Call payment API

const apiResponse = await axios.post('https://api.ifthenpay.com/spg/payment/mbway', data)

if (apiResponse.status === 200) {
const responseData = apiResponse.data
order.requestId = responseData.RequestId
order.status = 'Pending'
order.total = totalAmount
await order.save()

// Dispatch background job to update order status

await UpdateOrderStatus.dispatch(
{ requestId: order.requestId, email: authUser.email },
{ delay: 10000 }
).catch((error) => {
console.error('Error dispatching job', error)
})

return response.status(200).json({
order,
message: 'Payment initiated successfully',
})
} else {
return response.status(500).json({ message: 'Failed to initiate payment' })
}
} catch (error) {
console.error(error)
return response.status(500).json({
message: 'An error occurred while initiating the payment',
})
}
}

public async show({ inertia, params, auth, response }: HttpContext) {
const authUser = auth.user
if (!authUser) {
return response.status(401).json({
message: 'Unauthorized',
})
}

const order = await Order.find(params.id)
if (!order || (order.userId !== authUser.id)) {
return response.notFound({ message: 'Order not found' })
}
return inertia.render('payments/show', { order })
}
}
11 changes: 8 additions & 3 deletions website/app/controllers/tickets_controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import Ticket from '#models/ticket'
import Product from '#models/product'
import type { HttpContext } from '@adonisjs/core/http'

export default class TicketsController {
async index({ inertia }: HttpContext) {
const ticketTypes = await Ticket.all()
const ticketTypes = await Product.all()

return inertia.render('tickets', { ticketTypes })
}

async showPayment({ inertia, auth, params }: HttpContext) {
const ticket = await Product.find(params.id)

return inertia.render('payments/index', { ticket, user: auth.user })
}
}
74 changes: 74 additions & 0 deletions website/app/jobs/update_order_status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import axios from 'axios'
import Order from '#models/order'
import env from '#start/env'
import { Job } from 'adonisjs-jobs'
import ConfirmPaymentNotification from '#mails/confirm_payment_notification'
import mail from '@adonisjs/mail/services/main'
import db from '@adonisjs/lucid/services/db'

type UpdateOrderStatusPayload = {
requestId: string
email: string
}

export default class UpdateOrderStatus extends Job {
async handle({ requestId, email }: UpdateOrderStatusPayload) {
try {

this.logger.info(`Processing status update for requestId: ${requestId}`)

// Fetch the order based on the requestId
const order = await Order.query().where('request_id', requestId).first()
if (!order) {
this.logger.error(`Order with requestId ${requestId} not found`)
console.error(`Order with requestId ${requestId} not found`)
return
}

if (order.status !== 'Pending') {
this.logger.info(`Order status is no longer pending: ${order.status}`)
return // Exit if the status is no longer "Pending"
}
const apiResponse = await axios.get(
`https://api.ifthenpay.com/spg/payment/mbway/status?mbWayKey=${env.get('IFTHENPAY_MBWAY_KEY')}&requestId=${requestId}`
)

if (apiResponse.status === 200) {
const status = apiResponse.data.Message
if (status) {
if (status === 'Pending') {
await UpdateOrderStatus.dispatch({ requestId, email }, { delay: 10000 }) // Retry after 5 seconds
this.logger.info(`Requeued job for requestId: ${requestId}`)
return
}
order.status = status
await order.save()
this.logger.info(`Order status updated to: ${order.status}`)
if (order.status === 'Success') {
this.logger.info(`Gonna send mail: ${order.status}`)
const products = await db
.from('products')
.join('order_products', 'products.id', 'order_products.product_id')
.where('order_products.order_id', order.id)
.select('products.*', 'order_products.quantity as quantity')

const total = order.total
const orderId = order.id
await mail.send(new ConfirmPaymentNotification(email, products, total, orderId))
}
} else {
await UpdateOrderStatus.dispatch({ requestId, email }, { delay: 10000 }) // Retry after 5 seconds
}
} else {
this.logger.error(`Failed to fetch payment status for requestId: ${requestId}`)
console.error(`Failed to fetch payment status for requestId: ${requestId}`)
await UpdateOrderStatus.dispatch({ requestId, email }, { delay: 10000 }) // Retry after 5 seconds
}
} catch (error) {
this.logger.error(`Error updating order status: ${error.message}`)
console.error(`Error updating order status: ${error.message}`)

await UpdateOrderStatus.dispatch({ requestId, email }, { delay: 10000 })
}
}
}
Loading

0 comments on commit fb85d50

Please sign in to comment.