import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { z } from 'zod'; import { db } from '@/src/db/db_index' import { coupons, users, staffUsers, orders, couponApplicableUsers, couponApplicableProducts, orderStatus, reservedCoupons } from '@/src/db/schema' import { eq, and, like, or, inArray, lt } from 'drizzle-orm'; import dayjs from 'dayjs'; const createCouponBodySchema = z.object({ couponCode: z.string().optional(), isUserBased: z.boolean().optional(), discountPercent: z.number().optional(), flatDiscount: z.number().optional(), minOrder: z.number().optional(), targetUser: z.number().optional(), productIds: z.array(z.number()).optional().nullable(), applicableUsers: z.array(z.number()).optional(), applicableProducts: z.array(z.number()).optional(), maxValue: z.number().optional(), isApplyForAll: z.boolean().optional(), validTill: z.string().optional(), maxLimitForUser: z.number().optional(), exclusiveApply: z.boolean().optional(), }); const validateCouponBodySchema = z.object({ code: z.string(), userId: z.number(), orderAmount: z.number(), }); export const couponRouter = router({ create: protectedProcedure .input(createCouponBodySchema) .mutation(async ({ input, ctx }) => { const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input; // Validation: ensure at least one discount type is provided if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { throw new Error("Either discountPercent or flatDiscount must be provided (but not both)"); } // If user-based, applicableUsers is required (unless it's apply for all) if (isUserBased && (!applicableUsers || applicableUsers.length === 0) && !isApplyForAll) { throw new Error("applicableUsers is required for user-based coupons (or set isApplyForAll to true)"); } // Cannot be both user-based and apply for all if (isUserBased && isApplyForAll) { throw new Error("Cannot be both user-based and apply for all users"); } // If applicableUsers is provided, verify users exist if (applicableUsers && applicableUsers.length > 0) { const existingUsers = await db.query.users.findMany({ where: inArray(users.id, applicableUsers), columns: { id: true }, }); if (existingUsers.length !== applicableUsers.length) { throw new Error("Some applicable users not found"); } } // Get staff user ID from auth middleware const staffUserId = ctx.staffUser?.id; if (!staffUserId) { throw new Error("Unauthorized"); } // Generate coupon code if not provided let finalCouponCode = couponCode; if (!finalCouponCode) { // Generate a unique coupon code const timestamp = Date.now().toString().slice(-6); const random = Math.random().toString(36).substring(2, 8).toUpperCase(); finalCouponCode = `MF${timestamp}${random}`; } // Check if coupon code already exists const existingCoupon = await db.query.coupons.findFirst({ where: eq(coupons.couponCode, finalCouponCode), }); if (existingCoupon) { throw new Error("Coupon code already exists"); } const result = await db.insert(coupons).values({ couponCode: finalCouponCode, isUserBased: isUserBased || false, discountPercent: discountPercent?.toString(), flatDiscount: flatDiscount?.toString(), minOrder: minOrder?.toString(), productIds: productIds || null, createdBy: staffUserId, maxValue: maxValue?.toString(), isApplyForAll: isApplyForAll || false, validTill: validTill ? dayjs(validTill).toDate() : undefined, maxLimitForUser: maxLimitForUser, exclusiveApply: exclusiveApply || false, }).returning(); const coupon = result[0]; // Insert applicable users if (applicableUsers && applicableUsers.length > 0) { await db.insert(couponApplicableUsers).values( applicableUsers.map(userId => ({ couponId: coupon.id, userId, })) ); } // Insert applicable products if (applicableProducts && applicableProducts.length > 0) { await db.insert(couponApplicableProducts).values( applicableProducts.map(productId => ({ couponId: coupon.id, productId, })) ); } return coupon; }), getAll: protectedProcedure .input(z.object({ cursor: z.number().optional(), limit: z.number().default(50), search: z.string().optional(), })) .query(async ({ input }) => { const { cursor, limit, search } = input; let whereCondition = undefined; const conditions = []; if (cursor) { conditions.push(lt(coupons.id, cursor)); } if (search && search.trim()) { conditions.push(like(coupons.couponCode, `%${search}%`)); } if (conditions.length > 0) { whereCondition = and(...conditions); } const result = await db.query.coupons.findMany({ where: whereCondition, with: { creator: true, applicableUsers: { with: { user: true, }, }, applicableProducts: { with: { product: true, }, }, }, orderBy: (coupons, { desc }) => [desc(coupons.createdAt)], limit: limit + 1, }); const hasMore = result.length > limit; const couponsList = hasMore ? result.slice(0, limit) : result; const nextCursor = hasMore ? result[result.length - 1].id : undefined; return { coupons: couponsList, nextCursor }; }), getById: protectedProcedure .input(z.object({ id: z.number() })) .query(async ({ input }) => { const couponId = input.id; const result = await db.query.coupons.findFirst({ where: eq(coupons.id, couponId), with: { creator: true, applicableUsers: { with: { user: true, }, }, applicableProducts: { with: { product: true, }, }, }, }); if (!result) { throw new Error("Coupon not found"); } return { ...result, productIds: (result.productIds as number[]) || undefined, applicableUsers: result.applicableUsers.map(au => au.user), applicableProducts: result.applicableProducts.map(ap => ap.product), }; }), update: protectedProcedure .input(z.object({ id: z.number(), updates: createCouponBodySchema.extend({ isInvalidated: z.boolean().optional(), }), })) .mutation(async ({ input }) => { const { id, updates } = input; // Validation: ensure discount types are valid if (updates.discountPercent !== undefined && updates.flatDiscount !== undefined) { if (updates.discountPercent && updates.flatDiscount) { throw new Error("Cannot have both discountPercent and flatDiscount"); } } // If updating to user-based, applicableUsers is required if (updates.isUserBased && (!updates.applicableUsers || updates.applicableUsers.length === 0)) { const existingCount = await db.$count(couponApplicableUsers, eq(couponApplicableUsers.couponId, id)); if (existingCount === 0) { throw new Error("applicableUsers is required for user-based coupons"); } } // If applicableUsers is provided, verify users exist if (updates.applicableUsers && updates.applicableUsers.length > 0) { const existingUsers = await db.query.users.findMany({ where: inArray(users.id, updates.applicableUsers), columns: { id: true }, }); if (existingUsers.length !== updates.applicableUsers.length) { throw new Error("Some applicable users not found"); } } const updateData: any = { ...updates }; delete updateData.applicableUsers; // Remove since we use couponApplicableUsers table if (updates.discountPercent !== undefined) { updateData.discountPercent = updates.discountPercent?.toString(); } if (updates.flatDiscount !== undefined) { updateData.flatDiscount = updates.flatDiscount?.toString(); } if (updates.minOrder !== undefined) { updateData.minOrder = updates.minOrder?.toString(); } if (updates.maxValue !== undefined) { updateData.maxValue = updates.maxValue?.toString(); } if (updates.validTill !== undefined) { updateData.validTill = updates.validTill ? dayjs(updates.validTill).toDate() : null; } const result = await db.update(coupons) .set(updateData) .where(eq(coupons.id, id)) .returning(); if (result.length === 0) { throw new Error("Coupon not found"); } console.log('updated coupon successfully') // Update applicable users: delete existing and insert new if (updates.applicableUsers !== undefined) { await db.delete(couponApplicableUsers).where(eq(couponApplicableUsers.couponId, id)); if (updates.applicableUsers.length > 0) { await db.insert(couponApplicableUsers).values( updates.applicableUsers.map(userId => ({ couponId: id, userId, })) ); } } // Update applicable products: delete existing and insert new if (updates.applicableProducts !== undefined) { await db.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id)); if (updates.applicableProducts.length > 0) { await db.insert(couponApplicableProducts).values( updates.applicableProducts.map(productId => ({ couponId: id, productId, })) ); } } return result[0]; }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { const { id } = input; const result = await db.update(coupons) .set({ isInvalidated: true }) .where(eq(coupons.id, id)) .returning(); if (result.length === 0) { throw new Error("Coupon not found"); } return { message: "Coupon invalidated successfully" }; }), validate: protectedProcedure .input(validateCouponBodySchema) .query(async ({ input }) => { const { code, userId, orderAmount } = input; if (!code || typeof code !== 'string') { return { valid: false, message: "Invalid coupon code" }; } const coupon = await db.query.coupons.findFirst({ where: and( eq(coupons.couponCode, code.toUpperCase()), eq(coupons.isInvalidated, false) ), }); if (!coupon) { return { valid: false, message: "Coupon not found or invalidated" }; } // Check expiry date if (coupon.validTill && new Date(coupon.validTill) < new Date()) { return { valid: false, message: "Coupon has expired" }; } // Check if coupon applies to all users or specific user if (!coupon.isApplyForAll && !coupon.isUserBased) { return { valid: false, message: "Coupon is not available for use" }; } // Check minimum order amount const minOrderValue = coupon.minOrder ? parseFloat(coupon.minOrder) : 0; if (minOrderValue > 0 && orderAmount < minOrderValue) { return { valid: false, message: `Minimum order amount is ${minOrderValue}` }; } // Calculate discount let discountAmount = 0; if (coupon.discountPercent) { const percent = parseFloat(coupon.discountPercent); discountAmount = (orderAmount * percent) / 100; } else if (coupon.flatDiscount) { discountAmount = parseFloat(coupon.flatDiscount); } // Apply max value limit const maxValueLimit = coupon.maxValue ? parseFloat(coupon.maxValue) : 0; if (maxValueLimit > 0 && discountAmount > maxValueLimit) { discountAmount = maxValueLimit; } return { valid: true, discountAmount, coupon: { id: coupon.id, discountPercent: coupon.discountPercent, flatDiscount: coupon.flatDiscount, maxValue: coupon.maxValue, } }; }), generateCancellationCoupon: protectedProcedure .input( z.object({ orderId: z.number(), }) ) .mutation(async ({ input, ctx }) => { const { orderId } = input; // Get staff user ID from auth middleware const staffUserId = ctx.staffUser?.id; if (!staffUserId) { throw new Error("Unauthorized"); } // Find the order with user and order status information const order = await db.query.orders.findFirst({ where: eq(orders.id, orderId), with: { user: true, orderStatus: true, }, }); if (!order) { throw new Error("Order not found"); } // Check if order is cancelled (check if any status entry has isCancelled: true) // const isOrderCancelled = order.orderStatus?.some(status => status.isCancelled) || false; // if (!isOrderCancelled) { // throw new Error("Order is not cancelled"); // } // // Check if payment method is COD // if (order.isCod) { // throw new Error("Can't generate refund coupon for CoD Order"); // } // Verify user exists if (!order.user) { throw new Error("User not found for this order"); } // Generate coupon code: first 3 letters of user name or mobile + orderId const userNamePrefix = (order.user.name || order.user.mobile || 'USR').substring(0, 3).toUpperCase(); const couponCode = `${userNamePrefix}${orderId}`; // Check if coupon code already exists const existingCoupon = await db.query.coupons.findFirst({ where: eq(coupons.couponCode, couponCode), }); if (existingCoupon) { throw new Error("Coupon code already exists"); } // Get order total amount const orderAmount = parseFloat(order.totalAmount); // Calculate expiry date (30 days from now) const expiryDate = new Date(); expiryDate.setDate(expiryDate.getDate() + 30); // Create the coupon and update order status in a transaction const coupon = await db.transaction(async (tx) => { // Create the coupon const result = await tx.insert(coupons).values({ couponCode, isUserBased: true, flatDiscount: orderAmount.toString(), minOrder: orderAmount.toString(), maxValue: orderAmount.toString(), validTill: expiryDate, maxLimitForUser: 1, createdBy: staffUserId, isApplyForAll: false, }).returning(); const coupon = result[0]; // Insert applicable users await tx.insert(couponApplicableUsers).values({ couponId: coupon.id, userId: order.userId, }); // Update order_status with refund coupon ID await tx.update(orderStatus) .set({ refundCouponId: coupon.id }) .where(eq(orderStatus.orderId, orderId)); return coupon; }); return coupon; }), getReservedCoupons: protectedProcedure .input(z.object({ cursor: z.number().optional(), limit: z.number().default(50), search: z.string().optional(), })) .query(async ({ input }) => { const { cursor, limit, search } = input; let whereCondition = undefined; const conditions = []; if (cursor) { conditions.push(lt(reservedCoupons.id, cursor)); } if (search && search.trim()) { conditions.push(or( like(reservedCoupons.secretCode, `%${search}%`), like(reservedCoupons.couponCode, `%${search}%`) )); } if (conditions.length > 0) { whereCondition = and(...conditions); } const result = await db.query.reservedCoupons.findMany({ where: whereCondition, with: { redeemedUser: true, creator: true, }, orderBy: (reservedCoupons, { desc }) => [desc(reservedCoupons.createdAt)], limit: limit + 1, // Fetch one extra to check if there's more }); const hasMore = result.length > limit; const coupons = hasMore ? result.slice(0, limit) : result; const nextCursor = hasMore ? result[result.length - 1].id : undefined; return { coupons, nextCursor, }; }), createReservedCoupon: protectedProcedure .input(createCouponBodySchema) .mutation(async ({ input, ctx }) => { const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input; // Validation: ensure at least one discount type is provided if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { throw new Error("Either discountPercent or flatDiscount must be provided (but not both)"); } // For reserved coupons, applicableUsers is not used, as it's redeemed by one user // Get staff user ID from auth middleware const staffUserId = ctx.staffUser?.id; if (!staffUserId) { throw new Error("Unauthorized"); } // Generate secret code if not provided (use couponCode as base) let secretCode = couponCode || `SECRET${Date.now().toString().slice(-6)}${Math.random().toString(36).substring(2, 8).toUpperCase()}`; // Check if secret code already exists const existing = await db.query.reservedCoupons.findFirst({ where: eq(reservedCoupons.secretCode, secretCode), }); if (existing) { throw new Error("Secret code already exists"); } const result = await db.insert(reservedCoupons).values({ secretCode, couponCode: couponCode || `RESERVED${Date.now().toString().slice(-6)}`, discountPercent: discountPercent?.toString(), flatDiscount: flatDiscount?.toString(), minOrder: minOrder?.toString(), productIds, maxValue: maxValue?.toString(), validTill: validTill ? dayjs(validTill).toDate() : undefined, maxLimitForUser, exclusiveApply: exclusiveApply || false, createdBy: staffUserId, }).returning(); const coupon = result[0]; // Insert applicable products if provided if (applicableProducts && applicableProducts.length > 0) { await db.insert(couponApplicableProducts).values( applicableProducts.map(productId => ({ couponId: coupon.id, productId, })) ); } return coupon; }), getUsersMiniInfo: protectedProcedure .input(z.object({ search: z.string().optional(), limit: z.number().min(1).max(50).default(20), offset: z.number().min(0).default(0), })) .query(async ({ input }) => { const { search, limit } = input; let whereCondition = undefined; if (search && search.trim()) { whereCondition = or( like(users.name, `%${search}%`), like(users.mobile, `%${search}%`) ); } const userList = await db.query.users.findMany({ where: whereCondition, columns: { id: true, name: true, mobile: true, }, limit: limit, offset: input.offset, orderBy: (users, { asc }) => [asc(users.name)], }); return { users: userList.map(user => ({ id: user.id, name: user.name || 'Unknown', mobile: user.mobile, })) }; }), createCoupon: protectedProcedure .input(z.object({ mobile: z.string().min(1, 'Mobile number is required'), })) .mutation(async ({ input, ctx }) => { const { mobile } = input; // Get staff user ID from auth middleware const staffUserId = ctx.staffUser?.id; if (!staffUserId) { throw new Error("Unauthorized"); } // Clean mobile number (remove non-digits) const cleanMobile = mobile.replace(/\D/g, ''); // Validate: exactly 10 digits if (cleanMobile.length !== 10) { throw new Error("Mobile number must be exactly 10 digits"); } // Check if user exists, create if not let user = await db.query.users.findFirst({ where: eq(users.mobile, cleanMobile), }); if (!user) { // Create new user const [newUser] = await db.insert(users).values({ name: null, email: null, mobile: cleanMobile, }).returning(); user = newUser; } // Generate unique coupon code const timestamp = Date.now().toString().slice(-6); const random = Math.random().toString(36).substring(2, 6).toUpperCase(); const couponCode = `MF${cleanMobile.slice(-4)}${timestamp}${random}`; // Check if coupon code already exists (very unlikely but safe) const existingCode = await db.query.coupons.findFirst({ where: eq(coupons.couponCode, couponCode), }); if (existingCode) { throw new Error("Generated coupon code already exists - please try again"); } // Create the coupon const [coupon] = await db.insert(coupons).values({ couponCode, isUserBased: true, discountPercent: "20", // 20% discount minOrder: "1000", // ₹1000 minimum order maxValue: "500", // ₹500 maximum discount maxLimitForUser: 1, // One-time use isApplyForAll: false, exclusiveApply: false, createdBy: staffUserId, validTill: dayjs().add(90, 'days').toDate(), // 90 days from now }).returning(); // Associate coupon with user await db.insert(couponApplicableUsers).values({ couponId: coupon.id, userId: user.id, }); return { success: true, coupon: { id: coupon.id, couponCode: coupon.couponCode, userId: user.id, userMobile: user.mobile, discountPercent: 20, minOrder: 1000, maxValue: 500, maxLimitForUser: 1, }, }; }), });