From e6e14cc62ae65e700eecb3fb7e2ff8f20ff6c1f1 Mon Sep 17 00:00:00 2001 From: swetalin-10 Date: Sun, 21 Jun 2026 03:19:05 +0530 Subject: [PATCH 1/2] feat: promo codes and coupon system (closes #138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Coupon model: code (unique, uppercase), type (percentage|flat), value, minOrderAmount, maxUses, usedCount, expiresAt, isActive - POST /api/coupons/validate — public, validates code + cart total, returns discount and final total - POST /api/coupons — admin: create coupon - GET /api/coupons — admin: list all coupons - PATCH /api/coupons/:code/deactivate — admin: soft-deactivate - checkout.controller: accepts optional couponCode, validates it, applies discount by scaling Stripe line item unit_amounts proportionally; stores couponCode in session metadata; webhook increments usedCount after successful payment Frontend (Navbar cart drawer): - Promo code input with Apply button; validates against API - Applied coupon shown as dismissable tag with savings amount - Total section shows strikethrough original + discounted price - couponCode forwarded to /api/checkout on Proceed to Checkout --- BACKEND/app.js | 2 + BACKEND/controllers/checkout.controller.js | 44 ++++++++++- BACKEND/controllers/coupon.controller.js | 91 ++++++++++++++++++++++ BACKEND/models/coupon.model.js | 52 +++++++++++++ BACKEND/routes/coupon.route.js | 20 +++++ FRONTEND/src/components/ui/Navbar.jsx | 76 ++++++++++++++++-- 6 files changed, 277 insertions(+), 8 deletions(-) create mode 100644 BACKEND/controllers/coupon.controller.js create mode 100644 BACKEND/models/coupon.model.js create mode 100644 BACKEND/routes/coupon.route.js diff --git a/BACKEND/app.js b/BACKEND/app.js index 418568d..ca612a7 100644 --- a/BACKEND/app.js +++ b/BACKEND/app.js @@ -14,6 +14,7 @@ import wishlistRoutes from "./routes/wishlist.route.js"; import reviewRoutes from "./routes/review.route.js"; import ordersRoutes from "./routes/orders.route.js"; import userRoutes from "./routes/user.route.js"; +import couponRoutes from "./routes/coupon.route.js"; import passport from "./config/passport.js"; import swaggerUi from 'swagger-ui-express'; import swaggerSpec from './swagger.js'; @@ -110,6 +111,7 @@ app.use("/api/checkout", checkoutRoutes); app.use("/api/wishlist", wishlistRoutes); app.use("/api/orders", ordersRoutes); app.use("/api/user", userRoutes); +app.use("/api/coupons", couponRoutes); // ============= PRODUCTION STATIC FILES & REACT APP ============= diff --git a/BACKEND/controllers/checkout.controller.js b/BACKEND/controllers/checkout.controller.js index 6dcb8a5..c750c81 100644 --- a/BACKEND/controllers/checkout.controller.js +++ b/BACKEND/controllers/checkout.controller.js @@ -1,5 +1,6 @@ import Product from '../models/product.model.js'; import Order from '../models/order.model.js'; +import Coupon from '../models/coupon.model.js'; import mongoose from 'mongoose'; import Stripe from 'stripe'; @@ -28,7 +29,7 @@ async function restoreStock(deductions) { export const createCheckoutSession = async (req, res) => { try { - const { items } = req.body; + const { items, couponCode } = req.body; if (!items || !Array.isArray(items) || items.length === 0) { return res.status(400).json({ success: false, message: "Cart is empty or invalid" }); @@ -77,6 +78,37 @@ export const createCheckoutSession = async (req, res) => { }); } + // Validate and apply coupon discount if provided + let couponDoc = null; + let discountAmount = 0; + if (couponCode) { + const rawTotal = lineItems.reduce((sum, li) => sum + li.price_data.unit_amount * li.quantity, 0) / 100; + couponDoc = await Coupon.findOne({ code: couponCode.trim().toUpperCase(), isActive: true }); + + if (!couponDoc) { + return res.status(400).json({ success: false, message: 'Invalid or expired coupon code' }); + } + if (couponDoc.expiresAt && new Date() > couponDoc.expiresAt) { + return res.status(400).json({ success: false, message: 'This coupon has expired' }); + } + if (couponDoc.maxUses !== null && couponDoc.usedCount >= couponDoc.maxUses) { + return res.status(400).json({ success: false, message: 'Coupon usage limit reached' }); + } + if (rawTotal < couponDoc.minOrderAmount) { + return res.status(400).json({ success: false, message: `Minimum order of $${couponDoc.minOrderAmount.toFixed(2)} required` }); + } + + discountAmount = couponDoc.type === 'percentage' + ? (rawTotal * couponDoc.value) / 100 + : Math.min(couponDoc.value, rawTotal); + + // Apply discount by scaling each line item's unit_amount proportionally + const ratio = 1 - discountAmount / rawTotal; + for (const li of lineItems) { + li.price_data.unit_amount = Math.max(1, Math.round(li.price_data.unit_amount * ratio)); + } + } + const session = await stripe.checkout.sessions.create({ payment_method_types: ['card'], line_items: lineItems, @@ -88,6 +120,8 @@ export const createCheckoutSession = async (req, res) => { items.map((item) => ({ _id: item._id, quantity: item.quantity })) ), userId: req.user?._id?.toString() || '', + couponCode: couponDoc ? couponDoc.code : '', + discountAmount: discountAmount.toFixed(2), }, }); @@ -181,6 +215,14 @@ export const stripeWebhook = async (req, res) => { stripeSessionId: session.id, paymentStatus: "completed", }); + + // Increment coupon usage after successful fulfillment + if (session.metadata?.couponCode) { + await Coupon.findOneAndUpdate( + { code: session.metadata.couponCode }, + { $inc: { usedCount: 1 } } + ); + } } res.json({ received: true }); diff --git a/BACKEND/controllers/coupon.controller.js b/BACKEND/controllers/coupon.controller.js new file mode 100644 index 0000000..83d68a6 --- /dev/null +++ b/BACKEND/controllers/coupon.controller.js @@ -0,0 +1,91 @@ +import Coupon from '../models/coupon.model.js'; + +export const validateCoupon = async (req, res) => { + try { + const { code, orderTotal } = req.body; + if (!code) return res.status(400).json({ success: false, message: 'Coupon code is required' }); + + const coupon = await Coupon.findOne({ code: code.trim().toUpperCase(), isActive: true }); + + if (!coupon) return res.status(404).json({ success: false, message: 'Invalid or expired coupon code' }); + + if (coupon.expiresAt && new Date() > coupon.expiresAt) { + return res.status(400).json({ success: false, message: 'This coupon has expired' }); + } + + if (coupon.maxUses !== null && coupon.usedCount >= coupon.maxUses) { + return res.status(400).json({ success: false, message: 'This coupon has reached its usage limit' }); + } + + const total = Number(orderTotal) || 0; + if (total < coupon.minOrderAmount) { + return res.status(400).json({ + success: false, + message: `Minimum order amount of $${coupon.minOrderAmount.toFixed(2)} required`, + }); + } + + const discount = + coupon.type === 'percentage' + ? (total * coupon.value) / 100 + : Math.min(coupon.value, total); + + res.json({ + success: true, + data: { + code: coupon.code, + type: coupon.type, + value: coupon.value, + discount: Math.round(discount * 100) / 100, + finalTotal: Math.round((total - discount) * 100) / 100, + }, + }); + } catch (err) { + console.error('[Coupon] validate error:', err.message); + res.status(500).json({ success: false, message: 'Failed to validate coupon' }); + } +}; + +// Admin: create coupon +export const createCoupon = async (req, res) => { + try { + const { code, type, value, minOrderAmount, maxUses, expiresAt } = req.body; + if (!code || !type || value === undefined) { + return res.status(400).json({ success: false, message: 'code, type, and value are required' }); + } + if (type === 'percentage' && (value <= 0 || value > 100)) { + return res.status(400).json({ success: false, message: 'Percentage must be between 1 and 100' }); + } + const coupon = await Coupon.create({ code, type, value, minOrderAmount, maxUses, expiresAt }); + res.status(201).json({ success: true, data: coupon }); + } catch (err) { + if (err.code === 11000) return res.status(409).json({ success: false, message: 'Coupon code already exists' }); + console.error('[Coupon] create error:', err.message); + res.status(500).json({ success: false, message: 'Failed to create coupon' }); + } +}; + +// Admin: list all coupons +export const listCoupons = async (req, res) => { + try { + const coupons = await Coupon.find().sort({ createdAt: -1 }); + res.json({ success: true, data: coupons }); + } catch (err) { + res.status(500).json({ success: false, message: 'Failed to fetch coupons' }); + } +}; + +// Admin: deactivate coupon +export const deactivateCoupon = async (req, res) => { + try { + const coupon = await Coupon.findOneAndUpdate( + { code: req.params.code.toUpperCase() }, + { isActive: false }, + { new: true } + ); + if (!coupon) return res.status(404).json({ success: false, message: 'Coupon not found' }); + res.json({ success: true, data: coupon }); + } catch (err) { + res.status(500).json({ success: false, message: 'Failed to deactivate coupon' }); + } +}; diff --git a/BACKEND/models/coupon.model.js b/BACKEND/models/coupon.model.js new file mode 100644 index 0000000..031ddd7 --- /dev/null +++ b/BACKEND/models/coupon.model.js @@ -0,0 +1,52 @@ +import mongoose from 'mongoose'; + +const couponSchema = new mongoose.Schema( + { + code: { + type: String, + required: [true, 'Coupon code is required'], + unique: true, + uppercase: true, + trim: true, + minlength: [3, 'Code must be at least 3 characters'], + maxlength: [20, 'Code cannot exceed 20 characters'], + }, + type: { + type: String, + enum: ['percentage', 'flat'], + required: true, + }, + value: { + type: Number, + required: true, + min: [0, 'Discount value cannot be negative'], + }, + minOrderAmount: { + type: Number, + default: 0, + min: 0, + }, + maxUses: { + type: Number, + default: null, // null = unlimited + }, + usedCount: { + type: Number, + default: 0, + }, + expiresAt: { + type: Date, + default: null, // null = never expires + }, + isActive: { + type: Boolean, + default: true, + }, + }, + { timestamps: true } +); + +couponSchema.index({ code: 1 }); + +const Coupon = mongoose.model('Coupon', couponSchema); +export default Coupon; diff --git a/BACKEND/routes/coupon.route.js b/BACKEND/routes/coupon.route.js new file mode 100644 index 0000000..b622358 --- /dev/null +++ b/BACKEND/routes/coupon.route.js @@ -0,0 +1,20 @@ +import express from 'express'; +import { validateCoupon, createCoupon, listCoupons, deactivateCoupon } from '../controllers/coupon.controller.js'; +import authMiddleware from '../middleware/authMiddleware.js'; + +const isAdmin = (req, res, next) => { + if (req.user?.role !== 'admin') return res.status(403).json({ success: false, message: 'Admins only' }); + next(); +}; + +const router = express.Router(); + +// Public: validate a coupon during checkout +router.post('/validate', validateCoupon); + +// Admin: manage coupons +router.post('/', authMiddleware, isAdmin, createCoupon); +router.get('/', authMiddleware, isAdmin, listCoupons); +router.patch('/:code/deactivate', authMiddleware, isAdmin, deactivateCoupon); + +export default router; diff --git a/FRONTEND/src/components/ui/Navbar.jsx b/FRONTEND/src/components/ui/Navbar.jsx index a4b1692..8156189 100644 --- a/FRONTEND/src/components/ui/Navbar.jsx +++ b/FRONTEND/src/components/ui/Navbar.jsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Button, Container, Flex, HStack, Text, Input, useColorMode, useDisclosure, Drawer, DrawerBody, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerContent, DrawerCloseButton, - VStack, Box, Badge, useColorModeValue, useToast + VStack, Box, Badge, useColorModeValue, useToast, InputGroup, InputRightElement, Tag, TagLabel, TagCloseButton } from '@chakra-ui/react'; import { Link, useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -33,6 +33,9 @@ const Navbar = () => { const [isLoggedIn, setIsLoggedIn] = useState(false); const [isCheckoutLoading, setIsCheckoutLoading] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [promoInput, setPromoInput] = useState(''); + const [promoLoading, setPromoLoading] = useState(false); + const [appliedCoupon, setAppliedCoupon] = useState(null); // { code, discount, finalTotal } const totalItemsCount = cartItems.reduce((acc, item) => acc + item.quantity, 0); @@ -63,6 +66,30 @@ const Navbar = () => { return () => window.removeEventListener('open-cart', handleOpenCart); }, [handleCartOpen]); + const handleApplyPromo = async () => { + if (!promoInput.trim()) return; + setPromoLoading(true); + try { + const res = await fetch('/api/coupons/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: promoInput.trim(), orderTotal: totalPrice ?? 0 }), + }); + const data = await res.json(); + if (!data.success) { + toast({ title: 'Invalid coupon', description: data.message, status: 'error', duration: 3000, isClosable: true }); + return; + } + setAppliedCoupon(data.data); + setPromoInput(''); + toast({ title: `Coupon applied!`, description: `You save $${data.data.discount.toFixed(2)}`, status: 'success', duration: 3000, isClosable: true }); + } catch { + toast({ title: 'Error', description: 'Could not validate coupon', status: 'error', duration: 3000, isClosable: true }); + } finally { + setPromoLoading(false); + } + }; + const handleCheckout = async () => { if (cartItems.length === 0) return; setIsCheckoutLoading(true); @@ -71,7 +98,7 @@ const Navbar = () => { const res = await fetch("/api/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items: cartItems }), + body: JSON.stringify({ items: cartItems, couponCode: appliedCoupon?.code || null }), }); if (!res.ok) { throw new Error(`Server error: ${res.status} ${res.statusText}`); @@ -89,6 +116,7 @@ const Navbar = () => { return; } emptyCart(); + setAppliedCoupon(null); onClose(); navigate("/success"); } catch (err) { @@ -458,12 +486,46 @@ const Navbar = () => { )} - - + + {/* Promo code input */} + {cartItems.length > 0 && ( + appliedCoupon ? ( + + + {appliedCoupon.code} — save ${appliedCoupon.discount.toFixed(2)} + setAppliedCoupon(null)} /> + + + ) : ( + + setPromoInput(e.target.value.toUpperCase())} + onKeyDown={(e) => e.key === 'Enter' && handleApplyPromo()} + textTransform="uppercase" + /> + + + + + ) + )} + + {t('cart.total')}: - - {formatPrice(totalPrice ?? 0, currency, rates)} - + + {appliedCoupon && ( + + {formatPrice(totalPrice ?? 0, currency, rates)} + + )} + + {formatPrice(appliedCoupon ? appliedCoupon.finalTotal : (totalPrice ?? 0), currency, rates)} + +