Percentage, fixed-amount and comp codes, limited by event, ticket option or variant, dates, use counts, minimum quantity, and people, roles or an email domain. The discount is locked in on the registration (mirrored on Registration.discountTotal so every total sees it), shows on invoices, receipts, emails and the Yoco checkout, and is released on cancellation. Use limits are enforced under an advisory lock. Ticket options and variants can also carry a staff price that Staff and above get automatically. Adds a Supervisor > Coupon codes page, code fields on registration, My Events, manual registration, at the door and the kiosk, a Discounts report, and audit log entries. Also fixes Yoco line items and the free-registration check for lines spanning several price tranches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
332 lines
15 KiB
JavaScript
332 lines
15 KiB
JavaScript
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 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) });
|
||
}
|
||
};
|
||
|
||
// @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 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
|
||
}
|
||
|
||
// Price the basket the same way registration will.
|
||
const registrationOptions = [];
|
||
for (const o of options) {
|
||
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 }],
|
||
});
|
||
}
|
||
const lines = buildCouponLines({ registrationOptions });
|
||
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||
|
||
try {
|
||
const { coupon, discount } = 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,
|
||
subtotal,
|
||
total: Math.max(0, subtotal - 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 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 } } } },
|
||
},
|
||
});
|
||
res.json(rows.map(r => ({ ...r, summary: describeCoupon(r.coupon) })));
|
||
} catch (error) {
|
||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||
}
|
||
};
|
||
|
||
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions };
|