Major Features Added: - Complete Plugin Architecture System with financial plugin - Multi-currency support with exchange rates - Course type system (online, classroom, hybrid) - Attendance tracking and QR code scanning - Classroom sessions management - Course sections and content management - Professional video player with authentication - Secure media serving system - Shopping cart and checkout system - Financial dashboard and earnings tracking - Trainee progress tracking - User notes and assignments system Backend Infrastructure: - Plugin loader and registry system - Multi-currency database models - Secure media middleware - Course access middleware - Financial plugin with payment processing - Database migrations for new features - API endpoints for all new functionality Frontend Components: - Course management interface - Content creation and editing - Section management with drag-and-drop - Professional video player - QR scanner for attendance - Shopping cart and checkout flow - Financial dashboard - Plugin management interface - Trainee details and progress views This represents a major evolution of CourseWorx from a basic LMS to a comprehensive educational platform with plugin architecture.
376 lines
10 KiB
JavaScript
376 lines
10 KiB
JavaScript
/**
|
|
* Checkout Routes for Financial Plugin
|
|
*
|
|
* This file handles the checkout process including payment intent creation,
|
|
* payment confirmation, and order processing.
|
|
*/
|
|
|
|
const express = require('express');
|
|
const { body, validationResult } = require('express-validator');
|
|
const { auth } = require('../../../middleware/auth');
|
|
const { Cart, Order, OrderItem, Coupon } = require('../models');
|
|
// Initialize Stripe only if API key is available
|
|
let stripe = null;
|
|
try {
|
|
if (process.env.STRIPE_SECRET_KEY) {
|
|
stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Stripe not configured, payment processing will be simulated');
|
|
}
|
|
const { v4: uuidv4 } = require('uuid');
|
|
|
|
const router = express.Router();
|
|
|
|
// Middleware to get cart
|
|
const getCart = async (req, res, next) => {
|
|
try {
|
|
const { userId } = req.user || {};
|
|
const sessionId = req.headers['x-session-id'] || req.sessionID;
|
|
|
|
const cart = await Cart.findByUserOrSession(userId, sessionId);
|
|
|
|
if (!cart || cart.items.length === 0) {
|
|
return res.status(400).json({
|
|
error: 'Cart is empty'
|
|
});
|
|
}
|
|
|
|
if (cart.isExpired()) {
|
|
await cart.destroy();
|
|
return res.status(400).json({
|
|
error: 'Cart has expired'
|
|
});
|
|
}
|
|
|
|
req.cart = cart;
|
|
next();
|
|
} catch (error) {
|
|
console.error('Cart middleware error:', error);
|
|
res.status(500).json({ error: 'Failed to get cart' });
|
|
}
|
|
};
|
|
|
|
// @route POST /api/financial/checkout/create-intent
|
|
// @desc Create payment intent for checkout
|
|
// @access Private
|
|
router.post('/create-intent', [
|
|
auth,
|
|
getCart,
|
|
body('paymentMethodId').optional().isString().withMessage('Valid payment method ID is required')
|
|
], async (req, res) => {
|
|
try {
|
|
const errors = validationResult(req);
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({ errors: errors.array() });
|
|
}
|
|
|
|
const { paymentMethodId } = req.body;
|
|
const cart = req.cart;
|
|
const { userId } = req.user;
|
|
|
|
// Create order
|
|
const order = await Order.create({
|
|
userId,
|
|
orderNumber: Order.generateOrderNumber(),
|
|
status: 'pending',
|
|
totalAmount: cart.totalAmount,
|
|
discountAmount: cart.discountAmount,
|
|
taxAmount: cart.taxAmount,
|
|
finalAmount: cart.finalAmount,
|
|
couponId: cart.couponCode ? await Coupon.findByCode(cart.couponCode).then(c => c?.id) : null,
|
|
metadata: {
|
|
cartId: cart.id,
|
|
items: cart.items
|
|
}
|
|
});
|
|
|
|
// Create order items
|
|
for (const item of cart.items) {
|
|
await OrderItem.create({
|
|
orderId: order.id,
|
|
courseId: item.courseId,
|
|
courseType: item.type,
|
|
enrollmentType: 'one-time',
|
|
originalPrice: item.price,
|
|
finalPrice: item.price,
|
|
quantity: item.quantity,
|
|
metadata: {
|
|
addedAt: item.addedAt
|
|
}
|
|
});
|
|
}
|
|
|
|
// Create payment intent (Stripe or simulated)
|
|
let paymentIntent;
|
|
if (stripe) {
|
|
paymentIntent = await stripe.paymentIntents.create({
|
|
amount: Math.round(cart.finalAmount * 100), // Convert to cents
|
|
currency: 'usd', // This should come from plugin settings
|
|
metadata: {
|
|
orderId: order.id,
|
|
orderNumber: order.orderNumber,
|
|
userId: userId
|
|
},
|
|
payment_method: paymentMethodId,
|
|
confirmation_method: 'manual',
|
|
confirm: !!paymentMethodId
|
|
});
|
|
} else {
|
|
// Simulate payment intent when Stripe is not configured
|
|
paymentIntent = {
|
|
id: `pi_simulated_${Date.now()}`,
|
|
client_secret: `pi_simulated_${Date.now()}_secret`,
|
|
status: 'requires_confirmation'
|
|
};
|
|
}
|
|
|
|
res.json({
|
|
order: {
|
|
id: order.id,
|
|
orderNumber: order.orderNumber,
|
|
status: order.status,
|
|
totalAmount: parseFloat(order.totalAmount),
|
|
finalAmount: parseFloat(order.finalAmount)
|
|
},
|
|
paymentIntent: {
|
|
id: paymentIntent.id,
|
|
clientSecret: paymentIntent.client_secret,
|
|
status: paymentIntent.status
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Create payment intent error:', error);
|
|
res.status(500).json({ error: 'Failed to create payment intent' });
|
|
}
|
|
});
|
|
|
|
// @route POST /api/financial/checkout/confirm
|
|
// @desc Confirm payment and complete order
|
|
// @access Private
|
|
router.post('/confirm', [
|
|
auth,
|
|
body('paymentIntentId').isString().withMessage('Payment intent ID is required'),
|
|
body('orderId').isUUID().withMessage('Order ID is required')
|
|
], async (req, res) => {
|
|
try {
|
|
const errors = validationResult(req);
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({ errors: errors.array() });
|
|
}
|
|
|
|
const { paymentIntentId, orderId } = req.body;
|
|
const { userId } = req.user;
|
|
|
|
// Get order
|
|
const order = await Order.findByPk(orderId, {
|
|
where: { userId }
|
|
});
|
|
|
|
if (!order) {
|
|
return res.status(404).json({
|
|
error: 'Order not found'
|
|
});
|
|
}
|
|
|
|
if (order.status !== 'pending') {
|
|
return res.status(400).json({
|
|
error: 'Order is not pending'
|
|
});
|
|
}
|
|
|
|
// Confirm payment intent (Stripe or simulated)
|
|
let paymentIntent;
|
|
if (stripe) {
|
|
paymentIntent = await stripe.paymentIntents.confirm(paymentIntentId);
|
|
} else {
|
|
// Simulate payment confirmation when Stripe is not configured
|
|
paymentIntent = {
|
|
id: paymentIntentId,
|
|
status: 'succeeded'
|
|
};
|
|
}
|
|
|
|
if (paymentIntent.status === 'succeeded') {
|
|
// Mark order as paid
|
|
await order.markAsPaid(paymentIntent.id, 'card');
|
|
|
|
// Get order items
|
|
const orderItems = await OrderItem.findByOrder(orderId);
|
|
|
|
// Mark items as enrolled (this will trigger course enrollment)
|
|
for (const item of orderItems) {
|
|
await item.markAsEnrolled();
|
|
}
|
|
|
|
// Clear cart
|
|
const cart = await Cart.findByUserOrSession(userId, req.headers['x-session-id']);
|
|
if (cart) {
|
|
await cart.destroy();
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Payment successful',
|
|
order: {
|
|
id: order.id,
|
|
orderNumber: order.orderNumber,
|
|
status: order.status,
|
|
totalAmount: parseFloat(order.totalAmount),
|
|
finalAmount: parseFloat(order.finalAmount),
|
|
paidAt: order.paidAt
|
|
}
|
|
});
|
|
} else {
|
|
// Payment failed
|
|
await order.markAsFailed();
|
|
|
|
res.status(400).json({
|
|
success: false,
|
|
error: 'Payment failed',
|
|
order: {
|
|
id: order.id,
|
|
orderNumber: order.orderNumber,
|
|
status: order.status
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Confirm payment error:', error);
|
|
res.status(500).json({ error: 'Failed to confirm payment' });
|
|
}
|
|
});
|
|
|
|
// @route POST /api/financial/checkout/webhook
|
|
// @desc Handle Stripe webhooks
|
|
// @access Public (but secured with webhook signature)
|
|
router.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
const sig = req.headers['stripe-signature'];
|
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
|
|
let event;
|
|
|
|
try {
|
|
if (stripe) {
|
|
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
|
|
} else {
|
|
// Simulate webhook event when Stripe is not configured
|
|
event = {
|
|
type: 'payment_intent.succeeded',
|
|
data: {
|
|
object: {
|
|
id: 'pi_simulated_webhook',
|
|
status: 'succeeded'
|
|
}
|
|
}
|
|
};
|
|
}
|
|
} catch (err) {
|
|
console.error('Webhook signature verification failed:', err.message);
|
|
return res.status(400).send(`Webhook Error: ${err.message}`);
|
|
}
|
|
|
|
try {
|
|
// Handle the event
|
|
switch (event.type) {
|
|
case 'payment_intent.succeeded':
|
|
await handlePaymentSucceeded(event.data.object);
|
|
break;
|
|
case 'payment_intent.payment_failed':
|
|
await handlePaymentFailed(event.data.object);
|
|
break;
|
|
default:
|
|
console.log(`Unhandled event type ${event.type}`);
|
|
}
|
|
|
|
res.json({ received: true });
|
|
} catch (error) {
|
|
console.error('Webhook handler error:', error);
|
|
res.status(500).json({ error: 'Webhook handler failed' });
|
|
}
|
|
});
|
|
|
|
// Webhook handlers
|
|
async function handlePaymentSucceeded(paymentIntent) {
|
|
try {
|
|
const orderId = paymentIntent.metadata.orderId;
|
|
const order = await Order.findByPk(orderId);
|
|
|
|
if (order && order.status === 'pending') {
|
|
await order.markAsPaid(paymentIntent.id, 'card');
|
|
|
|
// Get order items and mark as enrolled
|
|
const orderItems = await OrderItem.findByOrder(orderId);
|
|
for (const item of orderItems) {
|
|
await item.markAsEnrolled();
|
|
}
|
|
|
|
console.log(`Order ${order.orderNumber} payment confirmed via webhook`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Handle payment succeeded error:', error);
|
|
}
|
|
}
|
|
|
|
async function handlePaymentFailed(paymentIntent) {
|
|
try {
|
|
const orderId = paymentIntent.metadata.orderId;
|
|
const order = await Order.findByPk(orderId);
|
|
|
|
if (order && order.status === 'pending') {
|
|
await order.markAsFailed();
|
|
console.log(`Order ${order.orderNumber} payment failed via webhook`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Handle payment failed error:', error);
|
|
}
|
|
}
|
|
|
|
// @route GET /api/financial/checkout/session/:orderId
|
|
// @desc Get checkout session details
|
|
// @access Private
|
|
router.get('/session/:orderId', [
|
|
auth
|
|
], async (req, res) => {
|
|
try {
|
|
const { orderId } = req.params;
|
|
const { userId } = req.user;
|
|
|
|
const order = await Order.findByPk(orderId, {
|
|
where: { userId },
|
|
include: [
|
|
{
|
|
model: OrderItem,
|
|
as: 'items'
|
|
}
|
|
]
|
|
});
|
|
|
|
if (!order) {
|
|
return res.status(404).json({
|
|
error: 'Order not found'
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
order: {
|
|
id: order.id,
|
|
orderNumber: order.orderNumber,
|
|
status: order.status,
|
|
totalAmount: parseFloat(order.totalAmount),
|
|
discountAmount: parseFloat(order.discountAmount),
|
|
taxAmount: parseFloat(order.taxAmount),
|
|
finalAmount: parseFloat(order.finalAmount),
|
|
paymentMethod: order.paymentMethod,
|
|
createdAt: order.createdAt,
|
|
paidAt: order.paidAt,
|
|
items: order.items
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Get checkout session error:', error);
|
|
res.status(500).json({ error: 'Failed to get checkout session' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|