const prisma = require('../config/db'); const { safeErrorMessage } = require('../utils/errorUtils'); const { logAdminAction } = require('../utils/adminAudit'); const { getClientIp } = require('../utils/requestUtils'); const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons'); const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing'); const { describeBundle } = require('../utils/bundles'); const TYPES = ['percent', 'fixed', 'comp']; const ROLES = ['user', 'staff', 'supervisor', 'admin']; const STAFF_ROLES = ['staff', 'supervisor', 'admin']; const COUPON_DETAIL_INCLUDE = { events: { include: { event: { select: { id: true, title: true, startDate: true } } } }, options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } }, allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } }, createdBy: { select: { id: true, name: true } }, }; const optionalDate = (v, label) => { if (v === undefined) return undefined; if (v === null || v === '') return null; const d = new Date(v); if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`); return d; }; const optionalInt = (v, label, min = 0) => { if (v === undefined) return undefined; if (v === null || v === '') return null; const n = Number(v); if (!Number.isInteger(n) || n < min) throw new Error(`${label} must be a whole number of at least ${min}`); return n; }; /** * Turns a create/update body into Prisma data plus the relation lists. On update, * fields left out of the body are left alone. */ async function parseCouponBody(body, { isCreate }) { const data = {}; if (isCreate || body.code !== undefined) { const code = normalizeCode(body.code); if (!/^[A-Z0-9_-]{3,32}$/.test(code)) { throw new Error('Codes are 3–32 letters, numbers, hyphens or underscores'); } data.code = code; } if (isCreate || body.type !== undefined) { if (!TYPES.includes(body.type)) throw new Error("Discount type must be 'percent', 'fixed' or 'comp'"); data.type = body.type; } if (isCreate || body.value !== undefined) { const type = data.type || body.type; const value = type === 'comp' ? 0 : Number(body.value); if (type !== 'comp' && !(value > 0)) throw new Error('Enter a discount value greater than zero'); if (type === 'percent' && value > 100) throw new Error('A percentage discount can\'t be more than 100'); data.value = value; } if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null; if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird; if (body.active !== undefined) data.active = !!body.active; const validFrom = optionalDate(body.validFrom, 'Valid from'); const validUntil = optionalDate(body.validUntil, 'Valid until'); if (validFrom !== undefined) data.validFrom = validFrom; if (validUntil !== undefined) data.validUntil = validUntil; if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Valid from" must be before "Valid until"'); const maxUses = optionalInt(body.maxUses, 'Max total uses', 1); const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1); const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1); if (maxUses !== undefined) data.maxUses = maxUses; if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser; if (minQuantity !== undefined) data.minQuantity = minQuantity; if (body.restrictRoles !== undefined) { const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : []; if (roles.some(r => !ROLES.includes(r))) throw new Error('Unknown role in restrictions'); data.restrictRoles = roles; } if (body.restrictDomain !== undefined) { const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, ''); if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) throw new Error('Enter an email domain like church.org'); data.restrictDomain = domain || null; } const eventIds = body.eventIds !== undefined ? [...new Set((body.eventIds || []).filter(Boolean))] : undefined; const scopes = body.options !== undefined ? (body.options || []).filter(o => o && o.eventOptionId).map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null })) : undefined; const userIds = body.userIds !== undefined ? [...new Set((body.userIds || []).filter(Boolean))] : undefined; if (scopes && scopes.length > 0) { const optionRows = await prisma.eventOption.findMany({ where: { id: { in: scopes.map(s => s.eventOptionId) } }, select: { id: true, eventId: true, variants: { select: { id: true } } }, }); for (const s of scopes) { const row = optionRows.find(o => o.id === s.eventOptionId); if (!row) throw new Error('One of the selected ticket options no longer exists'); if (s.variantId && !row.variants.some(v => v.id === s.variantId)) throw new Error('One of the selected variants no longer exists'); // A scoped option implies its event, so the code can't be used elsewhere by mistake. if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) { throw new Error('Selected ticket options must belong to the selected events'); } } } return { data, eventIds, scopes, userIds }; } function relationWrites({ eventIds, scopes, userIds }, isCreate) { const out = {}; if (eventIds !== undefined) out.events = { ...(isCreate ? {} : { deleteMany: {} }), create: eventIds.map(eventId => ({ eventId })) }; if (scopes !== undefined) out.options = { ...(isCreate ? {} : { deleteMany: {} }), create: scopes }; if (userIds !== undefined) out.allowedUsers = { ...(isCreate ? {} : { deleteMany: {} }), create: userIds.map(userId => ({ userId })) }; return out; } // @desc List coupons with their use counts // @route GET /api/coupons // @access Private/Supervisor const listCoupons = async (req, res) => { try { const coupons = await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } }); const totals = await prisma.couponRedemption.groupBy({ by: ['couponId'], where: { releasedAt: null, registration: { status: { not: 'cancelled' } } }, _count: { _all: true }, _sum: { discountAmount: true }, }); const byId = Object.fromEntries(totals.map(t => [t.couponId, t])); res.json(coupons.map(c => ({ ...c, summary: describeCoupon(c), uses: byId[c.id]?._count?._all || 0, totalDiscount: byId[c.id]?._sum?.discountAmount || 0, }))); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Get one coupon with its redemptions // @route GET /api/coupons/:id // @access Private/Supervisor const getCoupon = async (req, res) => { try { const coupon = await prisma.coupon.findUnique({ where: { id: req.params.id }, include: { ...COUPON_DETAIL_INCLUDE, redemptions: { orderBy: { createdAt: 'desc' }, include: { user: { select: { id: true, name: true, email: true } }, appliedBy: { select: { id: true, name: true } }, registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } }, }, }, }, }); if (!coupon) { res.status(404); throw new Error('Coupon not found'); } res.json({ ...coupon, summary: describeCoupon(coupon) }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Create a coupon // @route POST /api/coupons // @access Private/Supervisor const createCoupon = async (req, res) => { try { let parsed; try { parsed = await parseCouponBody(req.body || {}, { isCreate: true }); } catch (e) { res.status(400); throw e; } if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) { res.status(409); throw new Error(`The code ${parsed.data.code} already exists`); } const coupon = await prisma.coupon.create({ data: { ...parsed.data, createdById: req.user.id, ...relationWrites(parsed, true) }, include: COUPON_DETAIL_INCLUDE, }); logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'coupon_created', targetType: 'Coupon', targetId: coupon.id, metadata: { code: coupon.code, type: coupon.type, value: coupon.value }, ip: getClientIp(req), }); res.status(201).json({ ...coupon, summary: describeCoupon(coupon), uses: 0, totalDiscount: 0 }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Update a coupon (including turning it off). Discounts already applied keep // their locked amount. // @route PUT /api/coupons/:id // @access Private/Supervisor const updateCoupon = async (req, res) => { try { const existing = await prisma.coupon.findUnique({ where: { id: req.params.id } }); if (!existing) { res.status(404); throw new Error('Coupon not found'); } let parsed; try { parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false }); } catch (e) { res.status(400); throw e; } if (parsed.data.code && parsed.data.code !== existing.code) { const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } }); if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); } if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) { res.status(409); throw new Error(`The code ${parsed.data.code} already exists`); } } const coupon = await prisma.coupon.update({ where: { id: existing.id }, data: { ...parsed.data, ...relationWrites(parsed, false) }, include: COUPON_DETAIL_INCLUDE, }); const disabled = existing.active && coupon.active === false; const changedKeys = Object.keys(req.body || {}); logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated', targetType: 'Coupon', targetId: coupon.id, metadata: { code: coupon.code, changedKeys }, ip: getClientIp(req), }); const { usesTotal } = await countCouponUses(coupon.id, null); res.json({ ...coupon, summary: describeCoupon(coupon), uses: usesTotal }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; /** * Who a basket is being priced for: the logged-in buyer, or, for staff, the attendee * (`forUserId`) or a walk-in with no account yet (`forUserId: null`). */ async function basketBuyer(req, forUserId) { const isStaff = !!(req.user && STAFF_ROLES.includes(req.user.role)); let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null; if (isStaff && forUserId) { buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } }); } else if (isStaff && forUserId === null) { buyer = null; // a walk-in without an account yet } return { buyer, isStaff }; } /** Prices a basket the same way registration will, as coupon/bundle lines. */ async function priceBasket(event, options, buyer) { const registrationOptions = []; // A "pick sessions" line counts once per chosen session. const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o])); for (const o of expanded) { const eo = event.eventOptions.find(x => x.id === o.eventOptionId); const qty = Number(o.quantity || 0); if (!eo || qty < 1) continue; const resolved = o.variantId ? await resolveVariantTierPrice(eo, o.variantId, qty, buyer) : await resolveOptionPrice(eo, qty, buyer); registrationOptions.push({ eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo, tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }], }); } return buildCouponLines({ registrationOptions }); } // @desc Check a code against the items someone is about to register for // @route POST /api/coupons/validate // @access Public (login needed for restricted codes) // Body: { code, eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? } // Staff can pass forUserId to check on an attendee's behalf, or leave it out for a new // attendee — restricted codes are then checked when the registration is saved. const validateCode = async (req, res) => { try { const { code, eventId, options, forUserId } = req.body || {}; if (!code || !eventId || !Array.isArray(options) || options.length === 0) { res.status(400); throw new Error('code, eventId and options are required'); } const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } }, }); if (!event) { res.status(404); throw new Error('Event not found'); } const { buyer, isStaff } = await basketBuyer(req, forUserId); const lines = await priceBasket(event, options, buyer); const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0); try { const { coupon, discount, bundles } = await previewCoupon({ code, eventId, user: buyer, lines, deferRestrictions: isStaff && !buyer, }); res.json({ valid: true, code: coupon.code, type: coupon.type, value: coupon.value, summary: describeCoupon(coupon), description: coupon.description, discount, bundleDiscount: bundles.discount, bundles: bundles.applications, subtotal, total: Math.max(0, subtotal - bundles.discount - discount), }); } catch (e) { if (!e.isCouponError) throw e; res.status(400).json({ valid: false, message: e.message }); } } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Coupon and bundle-deal uses for the Discounts report // @route GET /api/coupons/redemptions?eventIds=a,b&from=&to= // @access Private/Supervisor const listRedemptions = async (req, res) => { try { const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean); const from = req.query.from ? new Date(req.query.from) : null; const to = req.query.to ? new Date(req.query.to) : null; const where = { releasedAt: null, registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}) }, ...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}), }; const rows = await prisma.couponRedemption.findMany({ where, orderBy: { createdAt: 'desc' }, include: { coupon: { select: { id: true, code: true, type: true, value: true } }, user: { select: { id: true, name: true, email: true } }, appliedBy: { select: { id: true, name: true } }, registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } }, }, }); // Bundle deals, in the same shape. Their rows are rebuilt whenever a registration's // discounts are recalculated, so dates go by when the registration was made. const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}; const bundleRows = await prisma.registrationBundle.findMany({ where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate } }, include: { bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } }, registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } }, }, }); res.json([ ...rows.map(r => ({ ...r, kind: 'coupon', uses: 1, summary: describeCoupon(r.coupon) })), ...bundleRows.map(b => ({ id: b.id, kind: 'bundle', uses: b.count, discountAmount: b.discount, createdAt: b.registration.createdAt, summary: describeBundle(b.bundle), coupon: { id: b.bundleId, code: b.bundle.name }, user: b.registration.user, appliedBy: null, registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event }, })), ]); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions, basketBuyer, priceBasket };