-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #14 from NIAEFEUP/1-buy-early-bird-ticket-flow
Feature: payments
- Loading branch information
Showing
46 changed files
with
2,073 additions
and
421 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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: |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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= | ||
|
||
FROM_EMAIL=[email protected] | ||
REPLY_TO_EMAIL=[email protected] | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -24,3 +24,5 @@ yarn-error.log | |
|
||
# Platform specific | ||
.DS_Store | ||
|
||
dump.rdb |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }) | ||
} | ||
} | ||
} |
Oops, something went wrong.